| 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 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
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about")({
component: AboutPage,
});
function AboutPage() {
return <div>About</div>;
}
Route with Loader
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
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:
import { getRuntimeConfig, getAccount, getAppName, getActiveRuntime, getRepository, getCspNonce } from "@/app";
import { createApiClient, useApiClient, useOrpc, type ApiClient } from "@/app";
import { createAuthClient, useAuthClient, sessionQueryOptions, useRelayHistory, type AuthClient, type SessionData } from "@/app";
import type { ClientRuntimeConfig, RouterContext, CreateRouterOptions, RenderOptions } from "@/app";
Runtime Helpers
const config = getRuntimeConfig();
const account = getAccount(config);
const appName = getAppName(config);
const activeRuntime = getActiveRuntime(config);
const repo = getRepository(config);
const nonce = getCspNonce();
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:
const apiClient = createApiClient({
hostUrl: runtimeConfig.hostUrl,
rpcBase: runtimeConfig.rpcBase,
});
It uses RPCLink with credentials: "include" and a global error interceptor that shows a toast on network failures.
In Route Components
import { useApiClient, useOrpc } from "@/app";
function Component() {
const apiClient = useApiClient();
const { data } = await apiClient.registry.listRegistryApps({ limit: 24 });
const orpc = useOrpc();
const { data, isLoading } = orpc.registry.listRegistryApps.useQuery({ limit: 24 });
const mutation = orpc.registry.listRegistryApps.useMutation();
const { apiClient } = context;
const result = await apiClient.ping();
}
Typed API Calls
Every procedure from every plugin is available on apiClient with full TypeScript types:
apiClient.ping()
apiClient.registry.listRegistryApps({ limit: 24 })
apiClient.projects.getProject({ id: "proj_123" })
apiClient.authHealth()
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:
try {
await apiClient.authHealth();
} catch (error) {
if (error instanceof ORPCError) {
}
}
Auth Client
Creation
The auth client is also created once in hydrate.tsx:
const authClient = createAuthClient(runtimeConfig);
Configured with Better-Auth plugins: SIWN (NEAR), passkey, organization, admin, API key, anonymous, phone.
In Route Components
import { useAuthClient, sessionQueryOptions } from "@/app";
function LoginPage() {
const authClient = useAuthClient();
await authClient.signIn.email({ email, password });
await authClient.signIn.siwn({ networkId: "mainnet" });
await authClient.signOut();
await authClient.organization.setActive({ organizationId: "org_123" });
}
Session Query Pattern
Use sessionQueryOptions() for standardized session fetching with TanStack Query:
const session = await queryClient.ensureQueryData(
sessionQueryOptions(authClient, context.session),
);
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:
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 defined inline in ui/src/routes/_layout.tsx as a sidebarItems array:
import { Globe, Home } from "lucide-react";
type SidebarRole = "anon" | "member" | "admin";
interface SidebarItem {
icon: React.ComponentType<{ className?: string }>;
label: string;
to: string;
roleRequired: SidebarRole;
}
Role filtering:
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 sidebarItems: SidebarItem[] = [
{ icon: Home, label: "home", to: "/home", roleRequired: "anon" },
];
const visibleItems = filterSidebarByRole(sidebarItems, userRole);
Add items manually to the sidebarItems array. Available icons: any lucide-react icon name.
SSR Architecture
Client Bootstrap (hydrate.tsx)
- Reads
window.__RUNTIME_CONFIG__
- Creates
QueryClient, ApiClient, AuthClient
- Creates TanStack Router with browser history
- Hydrates React DOM into the HTML shell
Server-Side (router.server.tsx)
- Creates request-scoped router with memory history
- Creates per-request
apiClient and authClient
- Renders to stream via TanStack Router SSR's
createRequestHandler
- 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:
export const Route = createFileRoute("/projects")({
beforeLoad: async ({ context }) => {
},
loader: async ({ context }) => {
const { apiClient } = context;
return await apiClient.projects.listProjects();
},
component: ProjectsPage,
});
The RouterContext type:
interface RouterContext extends BaseRouterContextWithApi<ApiClient, SessionData> {
apiClient: ApiClient;
authClient: AuthClient;
}
Component Patterns
Semantic Tailwind Classes
Use theme-aware classes — never hardcoded colors:
<div className="bg-background text-foreground">
<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:
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