Both clients/admin and clients/dashboard follow the same folder layout, the same auth model, the same API client conventions, and the same realtime patterns. They’re separate apps because their audiences are separate, but the patterns shouldn’t surprise you when you move between them.
Folder layout
clients/{app}/├── package.json React 19, Vite 7, TS 5.x, TanStack Query, Radix, etc.├── vite.config.ts Vite + React + Tailwind plugins + dev proxy config├── tsconfig.json├── index.html Single-page-app shell├── playwright.config.ts Route-mocked E2E test config (tests/ alongside)├── Dockerfile + docker/ Nginx runtime image + config.json template├── public/ Static assets + config.json (runtime config)└── src/ ├── main.tsx Loads /config.json, then createRoot + <App /> ├── App.tsx Top-level provider stack + RouterProvider ├── routes.tsx createBrowserRouter config (lazy routes) ├── env.ts Runtime config loader + typed accessor ├── api/ One file per backend module — typed apiFetch wrappers ├── auth/ JWT, token store, auth context, route guards, inactivity ├── components/ Shared components (layout, primitives, theme) ├── hooks/ Shared hooks (e.g. use-file-upload) ├── lib/ Shared helpers — api-client, query-client, cn ├── pages/ One folder (or file) per feature area ├── realtime/ SignalR context + hooks └── styles/ Global CSS + Tailwind baseThe dashboard adds sse/ (Server-Sent Events context for the activity feed).
The provider stack
// App.tsx (both apps)<ThemeProvider> <QueryClientProvider client={queryClient}> <AuthProvider> {/* admin: <RealtimeProvider> here; dashboard: a command-palette provider */} <RouterProvider router={router} /> <Toaster /> </AuthProvider> </QueryClientProvider></ThemeProvider>Order matters:
- Before any of this renders,
main.tsxtop-level-awaitsloadRuntimeConfig()so/config.jsonis in memory before the first component readsenv.*. ThemeProvideroutermost — light/dark/appearance applies even to the login screen.QueryClientProviderso every child can use TanStack Query.AuthProvider— contexts below depend on the token store + signed-in user.- Admin mounts its
RealtimeProviderhere; the dashboard mountsRealtimeProvider+SseProviderinside the authenticatedAppShellinstead, so public pages never open (or download) the realtime stack. - The router +
Toastercome last.
The API client
clients/{app}/src/lib/api-client.ts exports apiFetch<T>() — a hand-written wrapper over the browser’s fetch. No Axios, no generated client; the typed request/response shapes live next to each module’s functions in src/api/.
// Simplified — the real thing is ~160 linesexport async function apiFetch<T>(path: string, init: RequestInitEx = {}): Promise<T> { const headers = new Headers(init.headers); headers.set("Authorization", `Bearer ${tokenStore.getAccessToken()}`); headers.set("tenant", tokenStore.getTenant() ?? env.defaultTenant);
let response = await fetch(`${env.apiBase}${path}`, { ...init, headers, signal });
if (response.status === 401 && tokenStore.getRefreshToken()) { await refreshAccessToken(); // single-flight — concurrent 401s share one refresh response = await fetch(/* retry original request with the fresh token */); }
if (!response.ok) { const problem = await parseError(response); // ProblemDetails body, if JSON throw new ApiRequestError(response.status, problem?.title ?? response.statusText, problem); }
return response.json() as Promise<T>;}What it handles for every call:
- Auth + tenant headers from the token store (callers can override
tenantper request, or passskipAuthfor anonymous endpoints like login). - 401 → single-flight refresh → retry. Concurrent 401s share one in-flight refresh promise; if the refresh fails, tokens are cleared and the UI flips to
/login. - Timeouts — a 30-second default via
AbortSignal.timeout, merged with any caller-supplied signal. - Typed errors — non-2xx responses throw
ApiRequestError, which carries the HTTP status plus the parsed ProblemDetails payload.
React components can branch on it:
const { isError, error } = useQuery(...);
if (isError && error instanceof ApiRequestError) { return <Banner type="danger" title={error.message} detail={error.problem?.detail} />;}For validation errors (400 with an errors field), render the field errors inline:
if (mutation.isError && mutation.error instanceof ApiRequestError) { const fieldErrors = mutation.error.problem?.errors ?? {}; // fieldErrors.Sku?: string[] — render next to the input}TanStack Query conventions
One file per backend module under src/api/, exporting typed functions:
// clients/admin/src/api/users.ts (simplified excerpt)export async function searchUsers(params: SearchUsersParams = {}): Promise<PagedResponse<UserDto>> { const qs = new URLSearchParams(/* ...params... */); return apiFetch<PagedResponse<UserDto>>(`/api/v1/identity/users?${qs}`);}
export async function getUser(id: string): Promise<UserDto> { return apiFetch<UserDto>(`/api/v1/identity/users/${id}`);}
export async function registerUser(input: RegisterUserInput): Promise<RegisterUserResponse> { return apiFetch(`/api/v1/identity/users/register`, { method: "POST", body: JSON.stringify(input), });}Pages consume via React Query:
const usersQuery = useQuery({ queryKey: ['users', { search, page }], queryFn: () => searchUsers({ search, page }),});
const createMut = useMutation({ mutationFn: registerUser, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),});The queryKey is the canonical cache key. The kit uses arrays with one filter object per key entry — TanStack Query handles the hashing.
For mutations that touch multiple resource caches (e.g. deleting a user that affects roles + groups), invalidate them all in onSuccess.
Route guards
Two layers:
// Both apps: ProtectedRoute wraps the whole authenticated tree.// "Are you signed in?" — if not, redirect to /login.{ element: <ProtectedRoute />, children: [{ element: <AppShell />, children: [/* all app routes */] }],}
// Admin only: RouteGuard wraps individual route elements.// "Do you hold these permissions?" — if not, render <ForbiddenView>.{ path: "tenants", element: ( <RouteGuard perms={[MultitenancyPermissions.Tenants.View]}> <TenantsListPage /> </RouteGuard> ),}The guard mirrors the exact permission the server endpoint enforces — the constants in src/lib/permissions.ts track the server registries. The dashboard doesn’t wrap routes; it gates navigation entries and in-page actions against the fetched permission list instead (e.g. the recycle-bin tabs + nav entry only render for users holding a restore/view-trash permission). Either way the server stays the real enforcement layer; the client gating is UX.
Realtime context
src/realtime/realtime-context.tsx (in both apps) owns one shared SignalR connection to /api/v1/realtime/hub. Components never see the raw HubConnection — the context exposes a small surface:
type RealtimeContextValue = { status: "idle" | "connecting" | "connected" | "reconnecting" | "error"; /** Wire a handler for a server event. Returns an unsubscribe function. */ on: <T>(event: string, handler: (payload: T) => void) => () => void; /** Dashboard only: invoke a hub method, e.g. invoke("Typing", channelId). */ invoke: (method: string, ...args: unknown[]) => Promise<void>;};
// Most components use the convenience hook:useRealtimeEvent<NotificationDto>("NotificationCreated", (n) => { /* ... */ });Implementation details worth knowing:
- The
@microsoft/signalrpackage is dynamically imported on first connect, so pages without a realtime consumer never download it (~37 KB gzip). - The provider subscribes to the token store — login, refresh-token rotation, and impersonation swaps all rebuild the connection with the fresh token.
- Reconnects use SignalR’s automatic-reconnect backoff plus an outer retry loop with jitter, capped at 60 s.
- Transport is auto-negotiated (WebSockets → SSE → long-polling) for proxy-hostile networks.
One connection per user session; every subscriber attaches via on(event, handler) and the returned function detaches it. The dashboard’s chat page is the canonical user — see the dashboard page.
Error handling
The ApiRequestError shape (from clients/{app}/src/lib/api-client.ts):
export class ApiRequestError extends Error { readonly status: number; // HTTP status readonly problem?: ApiError; // parsed ProblemDetails payload}
export type ApiError = { status: number; title?: string; detail?: string; errors?: Record<string, string[]>; // field validation errors [key: string]: unknown;};Components render errors via page-level error bands, inline field errors next to inputs, or a toast (sonner) for ephemeral failures that don’t need persistent UI.
Runtime configuration
Neither app bakes environment values into the bundle. Instead, main.tsx fetches /config.json before mounting React:
// main.tsx (both apps)await loadRuntimeConfig(); // fetch("/config.json") → cachedcreateRoot(rootElement).render(<App />);
// anywhere elseimport { env } from "@/env";env.apiBase // "" in production (same-origin), or an absolute URLenv.defaultTenant // tenant header fallback, default "root"env.inactivityIdleMs // idle auto-logout timingIn dev, Vite serves public/config.json; in production, the Nginx container renders it from environment variables via envsubst at startup. Same image, every environment. The admin app additionally carries dashboardUrl (for the impersonation handoff); the dashboard carries demoMode (login-page demo-account picker). See development.
Inactivity auto sign-out
Both apps auto-sign-out idle sessions: admin after 10 minutes, dashboard after 20, each with a 60-second warning countdown — all four numbers configurable per deploy in config.json. The last-activity timestamp is shared across tabs via the fsh.lastActivity localStorage key so activity in any tab keeps the whole session alive.
What both apps don’t do
- No Redux / Zustand / global state manager. TanStack Query is the server state; React’s
useState/useReduceris the local state. Avoid pulling in a global store unless you have a genuine cross-page client-state need. - No CSS-in-JS. Tailwind utilities + a global stylesheet (plus the odd per-feature CSS file, like chat’s). The kit’s design language stays consistent through utility classes.
- No API client codegen. The
src/api/wrappers are written by hand against the OpenAPI doc — fewer moving parts than a generator, and the types only cover what the UI actually uses. - No microfrontends. Both apps are single-bundle SPAs (with per-route lazy chunks). Module federation is appealing in slide decks; it’s a real maintenance tax in practice.
Related
- Admin console — operator-facing surface.
- Tenant dashboard — end-user surface.
- Theming — per-tenant brand customisation.
- Local development — running, building, deploying.