Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

Architecture

Architecture

What it does

Next.js 16 App Router SaaS boilerplate: marketing site, authenticated user app, and an admin panel, sharing one Postgres database and one deploy.

Stack

  • Next.js 16.2 (App Router, Server Components, Server Actions, Turbopack). Middleware is renamed proxy.ts in this version (export function proxy(...)), and route params are Promise-wrapped — check node_modules/next/dist/docs/ before assuming older-Next behavior.
  • PostgreSQL via Drizzle ORM (drizzle-orm/node-postgres, pg.Pool).
  • better-auth — email/password + Google OAuth, Drizzle adapter, @better-auth/stripe plugin for subscriptions.
  • Stripe — multi-tier recurring billing.
  • Tailwind CSS v4 + shadcn/ui (Base UI-backed, vendored under components/ui/) — see ui.md. Full CSS-variable token contract in app/globals.css, class-based light/dark theming via next-themes, dark by default.
  • TypeScript, Zod for input validation in Server Actions.

How it works — route groups

app/
  (marketing)/   public site: /, /pricing, /contact — no auth required
  (auth)/        /login, /signup
  (app)/         authenticated user area: /dashboard, /billing, /settings, /subscribe
  (admin)/       admin-only: /admin, /admin/users, /admin/subscriptions, /admin/contacts
  api/auth/[...all]/route.ts   better-auth's catch-all handler

Groups don't affect URLs — they exist purely to give each area its own layout.tsx (nav, sidebar) without a prefix in the path.

Request flow / auth layering

Two layers, deliberately redundant:

  1. proxy.ts — a coarse, fast cookie-presence check (getSessionCookie). Redirects /dashboard, /billing, /settings, /subscribe, /admin/* to /login if there's no session cookie, and bounces logged-in users away from /login//signup. This is a UX optimization, not real authorization — a forged or stale cookie still passes this check.
  2. lib/dal.ts (the Data Access Layer) — the actual authorization, called at the top of every protected page/layout/Server Action:
    • verifySession() — real session lookup via auth.api.getSession, memoised per-request with React's cache(), redirects to /login if invalid.
    • requireAccess() — additionally requires a paid/trialing subscription (see billing.md); redirects to /subscribe.
    • requireAdmin() — additionally requires user.role === "admin"; 404s (not redirects) so the admin section's existence isn't leaked to non-admins.

Never trust proxy.ts alone to protect a route — always call the matching DAL function inside the page/action itself.

Data model

  • lib/db/schema/auth.ts — user, session, account, verification (all better-auth-managed) plus subscription (managed by @better-auth/stripe). user.role and user.stripeCustomerId are the only hand-added columns.
  • lib/db/schema/app.ts — your product tables. Currently just contactSubmissions. Add new tables here.
  • lib/db/schema/index.ts — re-exports both, imported everywhere as @/lib/db/schema.

Schema changes: edit the schema file → npm run db:generate (writes a migration into drizzle/) → npm run db:migrate (applies it). See setup.md.

Contact form

app/(marketing)/contact/page.tsx + components/marketing/contact-form.tsx (client, useActionState) → lib/actions/contact.ts (submitContact, Zod validated) → inserts into contactSubmissions. Nothing is emailed — submissions are read-only in app/(admin)/admin/contacts/page.tsx. See email.md and notifications.md (both stubs today) for wiring up an owner notification.

Extend

  • New product domain → new file under lib/db/schema/, exported from index.ts.
  • New protected area → new route group + layout.tsx calling the right DAL function, added to PROTECTED_PREFIXES in proxy.ts for the fast-redirect UX.
  • Server Actions live in lib/actions/*.ts, validated with Zod, "use server" at the top of the file.

Remove

There's nothing to "remove" at the architecture level — this is the skeleton everything else in docs/ builds on.