everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
384 lines (289 loc) • 11.4 kB
Markdown
---
name: ui-integration
description: Route creation, API client usage, auth client, SSR hydration, sidebar system, and the @/app module surface. Use when adding new UI routes, fetching data from the API, implementing auth flows, or customizing sidebar navigation.
metadata:
sources: "ui/src/app.ts,ui/src/lib/api.ts,ui/src/lib/auth.ts,ui/src/router.tsx,ui/src/router.server.tsx,ui/src/hydrate.tsx,ui/src/routes/__root.tsx,ui/src/routes/_layout.tsx,ui/src/routes/_layout/_authenticated.tsx,ui/src/lib/plugin-sidebar.gen.ts"
---
# UI Integration
## File-based Routing
Routes are defined as files in `ui/src/routes/`. TanStack Router auto-generates the route tree.
### Route File Convention
```
ui/src/routes/
├── __root.tsx # Root layout (HTML shell, head, scripts)
├── index.tsx # /
├── _layout.tsx # Shell layout (sidebar, header, footer)
├── _layout/
│ ├── index.tsx # / (inside shell)
│ ├── _authenticated.tsx # Auth guard layout (redirects to /login)
│ └── _authenticated/
│ ├── index.tsx # / (authenticated)
│ ├── settings.tsx # /settings
│ └── your-plugin/
│ └── index.tsx # /your-plugin
├── login.tsx # /login
└── about.tsx # /about
```
- Files starting with `_` are **layout** routes (parent components with `<Outlet />`)
- Files starting with `_` followed by a path segment are **nested layouts**
- Regular files become path segments (e.g., `settings.tsx` → `/settings`)
- Directories create nested paths (e.g., `_authenticated/settings.tsx` → `/settings` inside the auth guard)
### Basic Route
```tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about")({
component: AboutPage,
});
function AboutPage() {
return <div>About</div>;
}
```
### Route with Loader
```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useApiClient } from "@/app";
export const Route = createFileRoute("/projects/$id")({
loader: async ({ context, params }) => {
const { apiClient } = context;
const project = await apiClient.projects.getProject({ id: params.id });
return { project };
},
component: ProjectPage,
});
function ProjectPage() {
const { project } = Route.useLoaderData();
return <div>{project.name}</div>;
}
```
### Route with Head/Sidebar Metadata
```tsx
export const Route = createFileRoute("/_layout/settings")({
component: SettingsPage,
head: () => ({
meta: [{ title: "Settings" }],
}),
});
```
## The `@/app` Module Surface
`ui/src/app.ts` exports everything UI route code needs:
```ts
// Runtime config helpers
import { getRuntimeConfig, getAccount, getAppName, getActiveRuntime, getRepository, getCspNonce } from "@/app";
// API client
import { createApiClient, useApiClient, useOrpc, type ApiClient } from "@/app";
// Auth client
import { createAuthClient, useAuthClient, sessionQueryOptions, useRelayHistory, type AuthClient, type SessionData } from "@/app";
// Types
import type { ClientRuntimeConfig, RouterContext, CreateRouterOptions, RenderOptions } from "@/app";
```
### Runtime Helpers
```ts
const config = getRuntimeConfig(); // Full window.__RUNTIME_CONFIG__
const account = getAccount(config); // NEAR account
const appName = getAppName(config); // Title or account
const activeRuntime = getActiveRuntime(config); // { accountId, gatewayId, title }
const repo = getRepository(config); // Repository URL
const nonce = getCspNonce(); // CSP nonce for inline scripts
```
These are SSR-safe — they accept an optional `RuntimeConfigInput` to work with config from loader data.
## API Client
### Creation
The client is created once in `hydrate.tsx` and stored in the Router context:
```ts
const apiClient = createApiClient({
hostUrl: runtimeConfig.hostUrl,
rpcBase: runtimeConfig.rpcBase, // "/api/rpc"
});
```
It uses `RPCLink` with `credentials: "include"` and a global error interceptor that shows a toast on network failures.
### In Route Components
```ts
import { useApiClient, useOrpc } from "@/app";
function Component() {
// Direct usage
const apiClient = useApiClient();
const { data } = await apiClient.registry.listRegistryApps({ limit: 24 });
// With TanStack Query utils (caching, refetch, mutations)
const orpc = useOrpc();
const { data, isLoading } = orpc.registry.listRegistryApps.useQuery({ limit: 24 });
const mutation = orpc.registry.listRegistryApps.useMutation();
// In route loaders (from context, no hooks):
const { apiClient } = context;
const result = await apiClient.ping();
}
```
### Typed API Calls
Every procedure from every plugin is available on `apiClient` with full TypeScript types:
```ts
apiClient.ping() // → { status, timestamp }
apiClient.registry.listRegistryApps({ limit: 24 }) // → { apps, meta }
apiClient.projects.getProject({ id: "proj_123" }) // → Project
apiClient.authHealth() // → { status, emailConfigured, ... }
```
The types come from the auto-generated `api-types.gen.ts`, which merges every plugin's contract into a single `ApiContract` type.
### Error Handling
Network/fetch errors are automatically caught by the `RPCLink` interceptor and shown as a toast:
```
"Unable to connect to API" — The API is currently unavailable.
```
Procedure-level errors (like `UNAUTHORIZED`, `NOT_FOUND`) are thrown as `ORPCError` instances and should be caught inline:
```ts
try {
await apiClient.authHealth();
} catch (error) {
if (error instanceof ORPCError) {
// Handle specific error
}
}
```
## Auth Client
### Creation
The auth client is also created once in `hydrate.tsx`:
```ts
const authClient = createAuthClient(runtimeConfig);
```
Configured with Better-Auth plugins: SIWN (NEAR), passkey, organization, admin, API key, anonymous, phone.
### In Route Components
```ts
import { useAuthClient, sessionQueryOptions } from "@/app";
function LoginPage() {
const authClient = useAuthClient();
// Sign in with email
await authClient.signIn.email({ email, password });
// Sign in with NEAR (SIWN)
await authClient.signIn.siwn({ networkId: "mainnet" });
// Sign out
await authClient.signOut();
// Organization switching
await authClient.organization.setActive({ organizationId: "org_123" });
}
```
### Session Query Pattern
Use `sessionQueryOptions()` for standardized session fetching with TanStack Query:
```ts
// In a route loader:
const session = await queryClient.ensureQueryData(
sessionQueryOptions(authClient, context.session),
);
// In a component:
const { data: session } = useQuery(sessionQueryOptions(authClient));
```
Returns `SessionData` with `user`, `session`, and typed auth context.
## Auth Route Guard
The `_authenticated.tsx` layout protects routes that require a session:
```ts
export const Route = createFileRoute("/_layout/_authenticated")({
beforeLoad: async ({ context, location }) => {
const { queryClient, authClient } = context;
const session = await queryClient.ensureQueryData(
sessionQueryOptions(authClient, context.session),
);
if (!session?.user) {
throw redirect({
to: "/login",
search: { redirect: location.href },
});
}
if (session.user.banned) {
throw redirect({ to: "/login", hash: "banned" });
}
return {
auth: {
isAuthenticated: true,
user: session.user,
session: session.session,
activeOrganizationId: session.session?.activeOrganizationId || null,
isAnonymous: session.user.isAnonymous || false,
isAdmin: session.user.role === "admin",
isBanned: session.user.banned || false,
},
};
},
component: AuthenticatedLayout,
});
```
Nest routes under `_layout/_authenticated/` to inherit this guard. Unauthenticated users are redirected to `/login?redirect=<current-path>`.
## Sidebar System
Sidebar items are auto-generated by `bos types gen` into `ui/src/lib/plugin-sidebar.gen.ts` from `bos.config.json`.
### Plugin sidebar config:
```json
{
"plugins": {
"your-plugin": {
"sidebar": [
{ "to": "/your-plugin", "label": "Your Plugin", "icon": "Activity", "roleRequired": "member" }
]
}
}
}
```
### Role filtering in `_layout.tsx`:
```ts
import { pluginSidebarItems, type SidebarItem, type SidebarRole } from "@/lib/plugin-sidebar.gen";
function filterSidebarByRole(items: SidebarItem[], userRole: SidebarRole): SidebarItem[] {
return items.filter((item) => {
if (item.roleRequired === "anon") return true;
if (item.roleRequired === "member" && userRole !== "anon") return true;
if (item.roleRequired === "admin" && userRole === "admin") return true;
return false;
});
}
const userRole = isAdmin ? "admin" : isAuthenticated ? "member" : "anon";
const visibleItems = filterSidebarByRole(pluginSidebarItems, userRole);
```
Available icons: any `lucide-react` icon name as a string.
## SSR Architecture
### Client Bootstrap (`hydrate.tsx`)
1. Reads `window.__RUNTIME_CONFIG__`
2. Creates `QueryClient`, `ApiClient`, `AuthClient`
3. Creates TanStack Router with browser history
4. Hydrates React DOM into the HTML shell
### Server-Side (`router.server.tsx`)
1. Creates request-scoped router with memory history
2. Creates per-request `apiClient` and `authClient`
3. Renders to stream via TanStack Router SSR's `createRequestHandler`
4. Host calls `loadRouterModule()` to dynamically load the SSR bundle
### Route Loading for SSR
Loaders run on both server and client. Use `loader` for data needed at render time, `beforeLoad` for auth checks and redirects:
```ts
export const Route = createFileRoute("/projects")({
beforeLoad: async ({ context }) => {
// Runs on server and client — good for auth
},
loader: async ({ context }) => {
// Runs on server and client — good for data fetching
const { apiClient } = context;
return await apiClient.projects.listProjects();
},
component: ProjectsPage,
});
```
The `RouterContext` type:
```ts
interface RouterContext extends BaseRouterContextWithApi<ApiClient, SessionData> {
apiClient: ApiClient;
authClient: AuthClient;
}
```
## Component Patterns
### Semantic Tailwind Classes
Use theme-aware classes — never hardcoded colors:
```tsx
<div className="bg-background text-foreground"> // ✅ correct
<div className="bg-blue-600 text-white"> // ❌ wrong
<div className="text-muted-foreground"> // ✅ muted text
<div className="bg-card border border-border"> // ✅ card surface
```
### SSR-Safe Rendering
For values that only exist on the client:
```tsx
import { useClientValue } from "@/hooks";
function Component() {
const appName = useClientValue(() => getAppName(), "app");
return <h1>{appName}</h1>;
}
```
### Component Location
- Shared UI components: `ui/src/components/ui/` — semantic, reusable primitives
- Feature components: colocated with the route that uses them
- Exports from `ui/src/components/index.ts` for shared components