EGCA EGCA Engineering Handbook
Internal reference · v1
Home Onboarding Git Review Prototype ↔ Prod
Home Playbooks Next.js App

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. Keeps tsconfig paths 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 useActionState for 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 nuqs for 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/image always. Never <img> for non-trivial images.
  • next/font always. Never <link rel="font">.
  • Self-host fonts via next/font/local when 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.io tenant. Single-tenant app registration in Azure; domain check in the signIn callback as a second guard. No other account types allowed.
  • Client web / MVPClerk. 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 in lib/db/index.ts.
  • One Postgres. Migrations via Drizzle Kit. No drizzle-kit push past 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-nextjs for typed env vars. Fails at build time if missing.
  • Split server and client in the schema. A NEXT_PUBLIC_* leaking to server won’t be caught otherwise.

Error handling

  • app/error.tsx + app/global-error.tsx on 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/catch every line.

Loading & suspense

  • loading.tsx in 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 = 60 only 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-fns or Temporal (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.