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.tsin this version (export function proxy(...)), and routeparamsarePromise-wrapped — checknode_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/stripeplugin 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 inapp/globals.css, class-based light/dark theming vianext-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:
proxy.ts— a coarse, fast cookie-presence check (getSessionCookie). Redirects/dashboard,/billing,/settings,/subscribe,/admin/*to/loginif 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.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 viaauth.api.getSession, memoised per-request with React'scache(), redirects to/loginif invalid.requireAccess()— additionally requires a paid/trialing subscription (see billing.md); redirects to/subscribe.requireAdmin()— additionally requiresuser.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) plussubscription(managed by@better-auth/stripe).user.roleanduser.stripeCustomerIdare the only hand-added columns.lib/db/schema/app.ts— your product tables. Currently justcontactSubmissions. 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 fromindex.ts. - New protected area → new route group +
layout.tsxcalling the right DAL function, added toPROTECTED_PREFIXESinproxy.tsfor 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.