Fitness tracker on Next.js 16 (App Router), Postgres (Neon) and Better Auth
Type-checked against the real SDKs, migration applied to a live Postgres (Neon), connection clients load-tested, then tracked for upstream drift and re-verified when it moves. How we verify
session validation runs in server components and route handlers, not at the edge
What you're getting
Next.js 16 App Router — file-based routing, server components, and the Edge proxy (Next 16's renamed middleware).
Postgres on Neon via Drizzle ORM and the postgres-js driver.
Better Auth — self-hosted auth running inside your app against your Postgres (Drizzle adapter).
Fitness tracker — user-owned workout plans, a shared exercise catalog, append-only workout logs, and per-set reps/weight rows.
Setup
bun add next react react-dom drizzle-orm postgres better-authDATABASE_URLNeon pooled (-pooler) connection stringBETTER_AUTH_SECRETgenerate with `openssl rand -base64 32`BETTER_AUTH_URLyour app's base URLApply the schema with bunx drizzle-kit push
Initialization
Database client
Fitness tracker schema: plans, exercises, logs & sets
Workout plansuser-owned training plans that group and label a series of workout sessions
Exercise catalogshared, user-agnostic library of exercises keyed by unique name and optional muscle group
Workout logs & setsappend-only workout log rows with child log_sets recording per-set reps and weight
Deploy targets
Decisions and compatibility
Auth runs in proxy.ts (Next 16's renamed middleware) on the Edge runtime: it gates on the session cookie's presence only — full session validation happens in Server Components and route handlers, not in the proxy.
prepare: false is mandatory — Neon's pooled endpoint is PgBouncer in transaction mode, where server-side prepared statements break across the pool.
Drizzle is paired here (not Prisma): Prisma's prepared-statement reliance is incompatible with transaction-mode pooling.
Self-hosted: Better Auth owns the user/session/account/verification tables. This stack emits them (db/auth-schema.ts) and hands them to the Drizzle adapter, so app-type schemas can foreign-key `user` directly.
exercises.name carries a unique constraint — the exercise catalog is shared across all users, so duplicate names are a schema error, not a soft collision.
workout_logs.plan_id is set null on plan deletion, not cascaded — historical logs are preserved even when the originating plan is removed.