How to Add a Portal Page
This how-to walks you from an empty route file to a page that passes the portal CI gates. The example adds a hypothetical /widgets list backed by an apps/api endpoint. Prerequisites: read Portal architecture once, and skim DESIGN.md in the monorepo root — it is the design source of truth, and review flags anything that deviates.
The flow mirrors the request path: route → server-fn transport → server adapter → apps/api, with the display-only rule keeping the first box pure — because the browser bundle must never carry the WorkOS JWT, persistence types, or anything else that belongs on the Worker.
Create the route file
Routes are file-based under apps/portal/src/routes/; routeTree.gen.ts is generated (never hand-edit it — the dev/build tooling regenerates it from the filenames). Naming follows TanStack conventions you can see in the existing tree: widgets.index.tsx for the exact-match index, widgets.$widgetRef.tsx for a param child.
// apps/portal/src/routes/widgets.index.tsx
import { createFileRoute } from "@tanstack/react-router"
import { WidgetsListView } from "@/features/widgets/widgets-list-view"
export const Route = createFileRoute("/widgets/")({
head: () => ({ meta: [{ title: "Widgets — OpsDNA" }] }),
loader: async () => {
const { loadWidgetsForRoute } = await import("@/features/widgets/widgets-route-loader")
return loadWidgetsForRoute()
},
component: () => <WidgetsListView projection={Route.useLoaderData()} />,
})You do not wire the login or active-grant gates — they run globally in __root.tsx’s beforeLoad (see Auth & Tenancy). If the page needs an in-tenant capability gate, follow the pattern set by routes/companies.index.tsx: an L3 persona gate in beforeLoad plus an L2 deny-before-read loader backstop — and remember the API remains the real boundary.
Add the data adapter pair
Create the two-file pattern in apps/portal/src/server/ (copy an existing pair like kpi-client.ts / kpi-client.server.ts):
widgets-client.ts— acreateServerFntransport whose handler dynamically imports the server half. Wire shapes should come from a shared contract package, not ad-hoc types.widgets-client.server.ts— resolves the forwardable session JWT via@opsdna/portal-session, and fetches${API_BASE_URL}/widgets/...withauthorization: Bearer <jwt>,...activeTenantHeader(), and...previewApiAuthHeader(). HandleRequiresAuth→ auth-session-recovery redirect, and workspace-loss statuses → no-workspace recovery, exactly as the existing adapters do.
If the API endpoint doesn’t exist yet, that’s a /api change first — the portal only displays what a read-model or projection serves.
Keep the display layer display-only
Everything under routes/** and portal/** renders serialized projections. Runtime imports of @opsdna/persistence, @opsdna/ledger, @opsdna/canonical-json, migrations, or server-only modules fail portal:display-only-check. Type-only imports of persistence/ledger are fine (import type). Projection types belong in @opsdna/portal-read-models / @opsdna/portal-projections.
Build the UI per DESIGN.md
Use portal-ui components and portal-formatting helpers rather than bespoke markup. Fonts, colors, spacing, and posture come from DESIGN.md; the a11y baseline gate enforces per-tag accessibility rules, so semantic HTML and labeled controls are not optional.
Add a route test
Route-level tests sit next to the routes with a leading dash (e.g. -funds.$fundRef.index.test.tsx in apps/portal/src/routes/) so they’re excluded from route generation. Cover the loader’s deny/empty/ready projections at minimum.
Verify
bun run verifyRun it from the repo root before pushing — per-package typechecks are not a substitute. The portal-relevant gates it runs: portal:display-only-check, portal:bundle-audit (AppShell bundle budget — a heavy new dependency on a shared path will fail it), portal:a11y-baseline-check, portal:check-canonical-links, portal:check-route-health, portal:check-url-slug-identity, portal:trip-wire, and portal:check-dev-local-not-reachable.
Three pitfalls that burn time:
- Editing
src/ssr.tsx— it is dead code in both build targets; the real server entry is the TanStack virtual module wrapped byotel-worker-entry.ts. Nothing you change there ships. - Hand-editing
routeTree.gen.ts— it’s generated; your edit is overwritten on the next build. - “The portal gate already checked access, so the API doesn’t need to” — backwards. The active-grant gate has even been incident-bypassed in
__root.tsx;apps/api(JWT + RLS + capability checks) is the boundary. Every new data path needs API-side authorization regardless of portal gating.
Related
- Data Access — the adapter pattern in depth.
- Auth & Tenancy — what the gates do and don’t guarantee.
- /api — adding the endpoint your loader calls · /infra — how the portal deploys.