Architecture
- TanStack Start (React 19, Vite, Nitro) — file-based routing, SSR, server functions
- TanStack Query — server data, prefetched in route loaders and hydrated from SSR
- Supabase — Postgres and Auth (local stack in development, hosted in production)
- Drizzle ORM on node-postgres (
pg) — schema, migrations and queries - Tailwind v4 + shadcn/ui (
radix-nova), Recharts - Zod for input validation · Biome · Vitest
Code layout
Section titled “Code layout”src/ routes/ file-based routes __root.tsx document shell, providers, root error/404 _app.tsx signed-in app shell (sidebar) — auth guard in beforeLoad _app/dashboard.tsx home for admins and editors (placeholder) _app/users.tsx user management (admins) _app/account.tsx own account login.tsx, forgot-password.tsx, reset-password.tsx, accept-invite.tsx auth/callback.ts server route for Supabase email links server/ *.functions.ts createServerFn wrappers — safe to import anywhere *.server.ts server-only logic (DB, admin API) auth-middleware.ts requireUser / requireDashboardAccess / requireAdmin db/ Drizzle schema + server-only client lib/ shared logic (roles, password rules, email-link checks, …) start.ts global server-function middleware (error logging)supabase/ local config, migrations (generated by Drizzle), seedse2e/ end-to-end suitesdocs/ this siteImports use the #/ alias for src/ (Node subpath imports, package.json#imports).
How data reaches the page
Section titled “How data reaches the page”Drizzle query (*.server.ts) → server function (*.functions.ts, with auth middleware) → queryOptions (next to the route, e.g. usersQuery in _app/users.tsx) → route loader: queryClient.ensureQueryData(…) // runs during SSR → component: useSuspenseQuery(…) // reads the hydrated cache*.functions.tsfiles holdcreateServerFnwrappers. Client bundles get RPC stubs, so they can be imported from components and loaders.*.server.tsfiles hold everything that must never reach the browser. TanStack Start’s import protection fails the build if one is reachable from client code.- Read
process.envinside functions, never at module scope.
Errors
Section titled “Errors”src/start.ts registers a global server-function middleware. Every failure is logged on the server
with its full cause chain ([server-fn] failed … — Drizzle wraps driver errors in cause). In
production the browser only receives a generic error. Redirects and not-found pass through untouched.
Expected outcomes — wrong password, invalid input, “can’t remove yourself” — are returned values, not thrown errors, so they never show up as server errors. Validators only enforce bounds; the real rules run inside the handler and come back as field errors.
Content delivery (planned)
Section titled “Content delivery (planned)”Content types aren’t built yet. The intended model:
- The dashboard writes content server-side through Drizzle, behind the same role checks as today (admins and editors).
- Each content table gets a read-only public policy for published rows (for example
status = 'published'), so a site can fetch content withsupabase-jsor the REST API and the project’s publishable key. Drafts and private tables (like contact submissions) get no public policy. - Everything else keeps today’s default: RLS enabled, no policies, no public access.
Supabase Auth with email + password; sessions live in cookies via @supabase/ssr.
- Who is signed in:
getCurrentUser()usesauth.getClaims(), which verifies the JWT signature (and refreshes near-expiry sessions).getSession()is never trusted on the server. - Two layers: route guards (
beforeLoad) protect pages; middleware on each server function protects the endpoint itself, because server functions are directly callable. - Roles live in
app_metadata.role(admin,editor,user; missing or unknown →user). Helpers are insrc/lib/roles.ts.requireDashboardAccess— admin or editor, read from the JWT.requireAdmin— re-reads the role from the Auth server (getUser()), so a just-demoted admin can’t keep managing users.- Role changes reach JWT-based checks within one token lifetime (≤ 1 h).
- Accounts are invite-only. Public sign-up is disabled in
supabase/config.toml(locally and for production). - Email-link sessions:
/reset-passwordand/accept-invitemay set a password without the current one only for sessions created by a recent email link (amrmethodrecoveryorotp, within 15 minutes) — checked again inside the server function. - Email links:
- Password reset uses PKCE: the link lands on
/auth/callback?code=…and must be opened in the browser that requested it. The callback also acceptstoken_hashlinks for custom templates. - Invites don’t support PKCE: Supabase puts the session in the URL fragment, which
/accept-invitereads in the browser and hands to the server once.
- Password reset uses PKCE: the link lands on
UI components
Section titled “UI components”shadcn/ui components live in src/components/ui/ (generated; excluded from Biome). Add one with:
pnpm dlx shadcn@latest add <component>The shadcn CLI mangles the #/ alias. After adding components:
sed -i '' 's|from "cn"|from "#/lib/utils"|' src/components/ui/*.tsxgit checkout -- src/lib/utils.ts # if it was overwrittenIf it asks to overwrite an existing component (for example button.tsx), answer no.