This recipe walks a new screen into clients/dashboard end-to-end: the API module, the page component, the lazy route, the sidebar entry, permission gating, and the Playwright test. The running example is a Projects list-plus-create page; swap in your own resource. For the surrounding context — app shell, providers, design tokens — see the frontend architecture and dashboard app pages.
Step 1 — API module (src/api/projects.ts)
Types are hand-written — there is no codegen step. One file per feature in src/api/, exporting DTO types and thin async functions over apiFetch (from @/lib/api-client, which handles the bearer token, the tenant header, single-flight 401 refresh, and throws ApiRequestError on problem-details responses).
Condensed from the real src/api/tickets.ts:
import { apiFetch } from "@/lib/api-client";import type { PagedResponse } from "@/api/catalog"; // or re-declare inline
export type ProjectDto = { id: string; name: string; description?: string | null; createdAtUtc: string;};
export type CreateProjectInput = { name: string; description?: string | null };
const BASE = "/api/v1";
export function searchProjects(params: { search?: string; pageNumber?: number; pageSize?: number;} = {}): Promise<PagedResponse<ProjectDto>> { const q = new URLSearchParams(); if (params.search) q.set("search", params.search); q.set("pageNumber", String(params.pageNumber ?? 1)); q.set("pageSize", String(params.pageSize ?? 20)); return apiFetch<PagedResponse<ProjectDto>>(`${BASE}/projects?${q.toString()}`);}
export function createProject(input: CreateProjectInput): Promise<string> { return apiFetch<string>(`${BASE}/projects`, { method: "POST", body: JSON.stringify({ name: input.name, description: input.description ?? null }), });}Dashboard conventions to match (they differ from admin): camelCase query params (pageNumber, search), create mutations usually return Promise<string> (the new id), and PagedResponse<T> is either re-declared inline or imported from an existing api module — there’s no central api-types file here.
Step 2 — Page component (src/pages/projects/projects.tsx)
Pages are named exports (no default exports anywhere) and keep useQuery/useMutation inline. Query keys are hierarchical literal arrays with the params object last; paginated lists use placeholderData: keepPreviousData.
Condensed from src/pages/tickets/tickets.tsx:
export function ProjectsPage() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [pageNumber, setPageNumber] = useState(1);
useEffect(() => { const id = window.setTimeout(() => setDebouncedSearch(search.trim()), 300); return () => window.clearTimeout(id); }, [search]); useEffect(() => setPageNumber(1), [debouncedSearch]);
const query = useQuery({ queryKey: ["projects", "list", { search: debouncedSearch, pageNumber }], queryFn: () => searchProjects({ search: debouncedSearch || undefined, pageNumber }), placeholderData: keepPreviousData, });
// render with EntityPageHeader + the components/list family + components/ui primitives}UI building blocks:
- Primitives live in
src/components/ui/— cva-based, shadcn-style (button,card,dialog,input,badge,dropdown-menu, …). List/page furniture (theEntity*family likeEntityPageHeader) lives insrc/components/list/. - Tailwind v4 is CSS-first — no
tailwind.config; tokens are CSS variables insrc/styles/globals.css. Don’t hard-code colors, and keep dashboard neutrals at chroma 0. - Long lists should use
@tanstack/react-virtual.
Forms: hand-rolled, not react-hook-form
The dashboard does not depend on react-hook-form or zod — that’s an admin-only stack. Use controlled inputs with local useState and a plain onSubmit. List + create typically live in one file, with the editor in a <Dialog> (see CreateTicketDialog in the tickets page):
function CreateProjectDialog({ open, onClose, onCreated }: Props) { const [name, setName] = useState("");
useEffect(() => { if (open) setName(""); }, [open]); // reset on open
const mutation = useMutation({ mutationFn: (input: CreateProjectInput) => createProject(input), onSuccess: () => { toast.success("Project created"); onCreated(); onClose(); }, onError: (err: unknown) => toast.error(describe(err)), // describe() from @/lib/list-helpers });
const onSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); if (!name.trim()) return; mutation.mutate({ name: name.trim() }); }; // <Dialog open={open}><DialogContent><form onSubmit={onSubmit}>…}The parent invalidates the list when the dialog reports success:
<CreateProjectDialog open={editor.mode === "create"} onClose={() => setEditor({ mode: "closed" })} onCreated={() => void queryClient.invalidateQueries({ queryKey: ["projects"] })}/>Step 3 — Register the lazy route (src/routes.tsx)
Every page is code-split via the lazyNamed helper (adapts named exports to React.lazy’s default contract) and wrapped in withSuspense for a per-route skeleton fallback. Add the child route under AppShell — which already sits inside ProtectedRoute:
const ProjectsPage = lazyNamed(() => import("@/pages/projects/projects"), "ProjectsPage");
// inside the AppShell children array:{ path: "projects", element: withSuspense(<ProjectsPage />) },There is no per-route permission guard in the dashboard — ProtectedRoute is auth-only. Don’t port admin’s RouteGuard here; the server returns 403 for anything the user can’t do, and the nav hides entries they can’t reach (next step).
Step 4 — Sidebar nav entry (src/components/layout/nav-data.ts)
Nav is data-driven. Add a NavSpec to one of the sections (or topNavTop/topNavBottom), with a lucide icon:
{ id: "operations", caption: "Operations", icon: Activity, items: [ // … { to: "/projects", label: "Projects", icon: FolderKanban }, ],},Two optional gates control visibility:
perm: "Permissions.Projects.View"— hidden unless the user holds that exact permission.anyPerm: [...]— visible if the user holds any of the listed permissions. Used by the Trash entry, which fronts five independently-gated tabs.
visibleSections() filters items by the user’s permission list and drops sections left empty, so a gated entry never leads to a guaranteed 403.
Step 5 — Permission gating (nav + actions, not routes)
How permissions actually flow in the dashboard (verified in src/auth/auth-context.tsx):
- The JWT carries only role names, not permissions. After sign-in (and on every subject change, including impersonation),
AuthProviderfetchesGET /api/v1/identity/permissionsand caches the list in the token store (fsh.dashboard.permissions). - Components read it via
useAuth():user.permissionsplus apermissionsHydratedflag so gated UI doesn’t flash while the fetch is in flight. - Permission strings are mirrored by hand where the UI needs them, following the server registry convention
Permissions.{Resource}.{Action}. The pattern to copy issrc/lib/trash-permissions.ts— a smallas constmap with a comment naming the server permission each value mirrors.
Gate the nav entry (step 4) and any in-page actions (hide a Restore button, hide a tab) with user.permissions.includes(...). The server keeps enforcing everything as defence-in-depth — client gating is UX, not security.
Step 6 — Playwright test (tests/projects/projects.spec.ts)
Tests are route-mocked and JWT-seeded — no real backend. playwright.config.ts auto-boots npm run dev. The harness (in tests/helpers/):
seedAuthedSession(page, TEST_USER)writes a fake JWT (junk signature — the client never validates it) into thefsh.dashboard.*localStorage keys viaaddInitScript, before React boots.installShellMocks(page)stubs every call the authenticated AppShell fires on load — notifications, chat badge, profile, permissions, tenant status — and aborts the SSE/SignalR transports.mockJsonResponse/mockProblemDetails(inapi-mocks.ts) mock individual endpoints;paged([...])builds a paged response body.
Condensed from tests/tickets/tickets-list.spec.ts:
import { expect, test } from "@playwright/test";import { mockJsonResponse } from "../helpers/api-mocks";import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";import { installShellMocks, paged } from "../helpers/shell-mocks";
test.beforeEach(async ({ page }) => { await seedAuthedSession(page, TEST_USER); await installShellMocks(page); // Page-specific mocks AFTER shell mocks — Playwright matches the most // recently registered route first, so these win over the broad defaults. await mockJsonResponse(page, "**/api/v1/projects**", paged([SAMPLE_PROJECT]));});
test("renders the list", async ({ page }) => { await page.goto("/projects"); await expect(page.getByRole("heading", { name: /projects/i })).toBeVisible();});Two more harness gotchas, both verified in the existing suite:
- Permission-gated UI: the shell mock returns
[]forGET /identity/permissions, so gated entries are hidden by default. Re-mock it with the permissions your test needs (seetests/system/trash.spec.ts). - Strict-mode double match: list pages render a mobile card list and a desktop table of the same data. Scope assertions to one of them (the tickets spec filters to the desktop card) or
getByTextwill find two elements.
Step 7 — Verify
cd clients/dashboard && npm run lint && npm run test:e2eHow the admin app differs
Same skeleton, different conventions — don’t cross-pollinate:
- Routes are permission-gated: admin wraps elements in
<RouteGuard perms={[CatalogPermissions.Products.View]}>instead ofwithSuspensealone, and every permission is mirrored as a constant insrc/lib/permissions.ts. - Forms use react-hook-form + zod (
useForm+zodResolver), not controlled state. - Query params are PascalCase (
PageNumber,Search), andPagedResponse<T>imports from@/lib/api-types. - List and create are separate routed pages (
list.tsx,create.tsx), not one file with dialogs.
See the admin app reference for the full set.
Checklist
- API module in
src/api/{feature}.ts— hand-written types,apiFetch, camelCase params,PagedResponse<T>inline or imported from an existing module. - Page in
src/pages/{area}/{name}.tsx— named export; hierarchical query key with params object last;placeholderData: keepPreviousData. - Mutations pass per-call data through
mutate(arg); invalidate the list key inonSuccess; hand-rolled controlled form in a<Dialog>. - Route registered in
src/routes.tsxvialazyNamed+withSuspense, underAppShell— no route-level permission guard. - Nav entry in
src/components/layout/nav-data.ts, gated withperm/anyPermif the server endpoint requires a permission; mirror the permission stringPermissions.{Resource}.{Action}style (pattern:src/lib/trash-permissions.ts). - Playwright spec in
tests/{area}/{name}.spec.ts—seedAuthedSession+installShellMocksfirst, page mocks after; every on-load endpoint mocked; assertions scoped against the mobile/desktop double render. npm run lint && npm run test:e2egreen.
Related
- Frontend architecture — shared stack, API client, runtime config, providers.
- Dashboard app — tenant app divergences: SSE, impersonation, theming.
- Frontend development — dev server, proxy, testing workflow.
- AI route: point your coding agent at
.agents/skills/add-react-page/SKILL.mdin the repo — it scaffolds this exact recipe for either app.