Playbook — Next.js App
Covers internal tools and client web apps. App Router only. No new Pages Router projects.
Project shape
app/ # App Router
(marketing)/ # Route groups for layout sharing
(app)/
dashboard/
page.tsx
page.test.tsx # Colocated tests — yes, in the same dir
api/ # Only for non-Next consumers
lib/
db/ # Drizzle schema + client
auth/ # auth() helper, middleware
components/
ui/ # shadcn/ui primitives
features/ # Feature-scoped, not global
tests/
integration/
smoke.spec.ts # Playwright
src/dir: yes. Keepstsconfigpaths tidy, separates config from code.- Colocate tests with the code they test (
page.tsx+page.test.tsx). Big E2E goes in/tests.
Data fetching
- Default: Server Components fetch directly. No client-side fetch for initial page data.
- Mutations: Server Actions. Pair with
useActionStatefor form UX. - tRPC / REST: only when a non-Next client consumes the API (mobile, external).
- React Query: only for genuine client-driven fetches (infinite scroll, polling, optimistic). Not for page-load data.
Forms
- Server Actions + Zod validation. Return field errors, render them with
useActionState. - React Hook Form: only when client-side interactions are complex (wizard, cross-field validation). Don’t reach for it reflexively.
- Never Formik. It’s done.
State
- URL state (search params) for anything a user might share or bookmark. Use
nuqsfor typed search params. - Server state via Server Components (it’s just a fetch).
- Zustand for genuine cross-tree client state (global UI like a command palette). Tiny, boring, correct.
- No Redux. No Recoil. No MobX.
Styling
- Tailwind + shadcn/ui. Copy components into
components/ui/— you own them. - No CSS-in-JS (emotion, styled-components). Build-time cost, runtime cost, no benefit over Tailwind.
- Design tokens in
tailwind.config.ts, not scattered in components.
Images & fonts
next/imagealways. Never<img>for non-trivial images.next/fontalways. Never<link rel="font">.- Self-host fonts via
next/font/localwhen a client has licensing constraints.
Auth
Picker from 02-architecture § Auth:
- Internal tool or admin dashboard → Auth.js with Microsoft Entra provider, restricted to the
@egca.iotenant. Single-tenant app registration in Azure; domain check in thesignIncallback as a second guard. No other account types allowed. - Client web / MVP → Clerk. Middleware handles route protection in 3 lines. Never Entra — clients aren’t on our tenant.
- Client web / custom UI brand requirements → Auth.js with email + OAuth providers.
- Client app with an admin panel → split. Client UI on Clerk; admin routes behind a separate Entra-gated subdomain (e.g.
admin.app.com) or a separate Next.js app entirely. Don’t mix both providers in one middleware.
Always protect via middleware.ts on the matcher, not by checking auth() inside every page.
Database
- Drizzle default. Schema in
lib/db/schema.ts, client inlib/db/index.ts. - One Postgres. Migrations via Drizzle Kit. No
drizzle-kit pushpast prototype. - Local: Docker Postgres via
docker compose up. Preview: Neon or Supabase. Production: Azure Postgres, always — see 02-architecture § Database. - Pool the connection — use Neon/Supabase pooled URLs on preview, PgBouncer in front of Azure Postgres in prod. Serverless + unpooled = death.
Environment & config
@t3-oss/env-nextjsfor typed env vars. Fails at build time if missing.- Split
serverandclientin the schema. ANEXT_PUBLIC_*leaking to server won’t be caught otherwise.
Error handling
app/error.tsx+app/global-error.tsxon every Next app. They exist for a reason.- Throw typed errors in Server Actions; render them with
useActionState. - Datadog / Application Insights captures unhandled — you don’t need to
try/catchevery line.
Loading & suspense
loading.tsxin every route that does real data fetching.- Stream with Suspense boundaries for slow sections. Don’t block the whole page on one slow query.
Performance defaults
- Route segments default to SSR. Add
export const dynamic = 'force-static'/revalidate = 60only when you know the data is static-safe. - Parallel data fetches in Server Components —
await Promise.all([...]), not sequential awaits. - Bundle analysis before any release:
ANALYZE=true pnpm build. Look for surprises.
What NOT to pull in
- Material UI / Chakra / Ant Design. Tailwind + shadcn covers it.
- Moment.js. Use
date-fnsorTemporal(when stable). - Lodash. Native JS covers 95%; pick specific lodash functions only when genuinely needed.
- A state library for “just sharing a value between two components.” Props or Context are fine.