Skip to Content

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 — a createServerFn transport 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/... with authorization: Bearer <jwt>, ...activeTenantHeader(), and ...previewApiAuthHeader(). Handle RequiresAuth → 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 verify

Run 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:

  1. Editing src/ssr.tsx — it is dead code in both build targets; the real server entry is the TanStack virtual module wrapped by otel-worker-entry.ts. Nothing you change there ships.
  2. Hand-editing routeTree.gen.ts — it’s generated; your edit is overwritten on the next build.
  3. “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.
  • 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.
Last updated on