Skip to content

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
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), seeds
e2e/ end-to-end suites
docs/ this site

Imports use the #/ alias for src/ (Node subpath imports, package.json#imports).

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.ts files hold createServerFn wrappers. Client bundles get RPC stubs, so they can be imported from components and loaders.
  • *.server.ts files 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.env inside functions, never at module scope.

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 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 with supabase-js or 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() uses auth.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 in src/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-password and /accept-invite may set a password without the current one only for sessions created by a recent email link (amr method recovery or otp, 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 accepts token_hash links for custom templates.
    • Invites don’t support PKCE: Supabase puts the session in the URL fragment, which /accept-invite reads in the browser and hands to the server once.

shadcn/ui components live in src/components/ui/ (generated; excluded from Biome). Add one with:

Terminal window
pnpm dlx shadcn@latest add <component>

The shadcn CLI mangles the #/ alias. After adding components:

Terminal window
sed -i '' 's|from "cn"|from "#/lib/utils"|' src/components/ui/*.tsx
git checkout -- src/lib/utils.ts # if it was overwritten

If it asks to overwrite an existing component (for example button.tsx), answer no.