PERN Stack Roadmap

Express API · React client · Next.js alternative

The ordered build plan for a whole PERN project — PostgreSQL + Express + React + Node — nine phases from a blank folder to a monitored production deployment of both halves. One phase at a time: no phase starts before the previous deliverable exists. Estimates assume one experienced developer; solo, double them.

This is the umbrella roadmap: it sequences the project as one system and flags the decisions that span both halves (repo shape, contract, auth/cookies, deploy order). Per-half task detail lives in the sibling roadmaps — the Express API and the React client — which run under Phases 1–6 of this one. If the fork in Phase 0 goes the other way, take the Next.js roadmap instead; there is no PERN to coordinate then.

The checkboxes are the work. This file is what actually moves the project forward.


Phase 0 — Spec (2–3 days)

Goal: one written definition of the system before any tooling, agreed by both halves.

  • One-paragraph product statement ("For [user], this product does [job], unlike [alt]")
  • Up to 10 user stories for release 1 — resist more
  • Resource inventory: nouns, CRUD verbs, and auth requirement per endpoint — this becomes the API's route table
  • Screen inventory: name + one line per screen — this becomes the client's route table
  • One data model draft: entities, IDs, relationships, ownership rules — written once, not per half
  • Non-functional requirements: auth? realtime? uploads? offline? i18n? SEO? a11y target (WCAG 2.2 AA default)
  • Fork recorded: PERN split (this file) vs Next.js — one sentence of rationale each
  • Repo shape decided: pnpm monorepo (apps/api, apps/web, packages/shared) — the default — vs two repos when the halves ship on fully independent cadences
  • Contract source of truth picked: shared zod schemas in packages/shared (monorepo) or an OpenAPI document exported by CI
  • Domain + cookie plan sketched now: app.example.com + api.example.com, SameSite/Domain decided in writing — the classic PERN deploy bug is cheapest to fix on paper
  • Environments planned: dev / staging / prod, one API base URL per environment per half

Deliverable: one page a stranger could build both halves and the schema from.

Watch for: two nouns lists (one for the API, one for the screens) mean the halves are already drifting — there is one inventory and one model.

Phase 1 — Foundation (2–3 days)

Goal: an empty but correct monorepo: both apps boot, type-check, lint, CI green.

  • Node 24 LTS (22.13+ floor) and pnpm installed; pinned in .nvmrc + root engines
  • Workspace scaffolded: apps/api, apps/web, packages/shared (TS strict + noUncheckedIndexedAccess everywhere)
  • API: Express 5 skeleton, layout src/routes/ src/services/ src/db/ src/middleware/ src/lib/, GET /healthz + /readyz
  • Web: Vite + React 19 + TypeScript strict, React Router v7 route table from Phase 0, root providers (QueryClient, theme, error boundary)
  • Shared: the contract package stubbed — env parsing (zod) and the first schema exported
  • Config: typed env per app (node --env-file / VITE_*), .env.example with {{UPPER_SNAKE}} placeholders
  • ESLint 9 flat + Prettier + husky + lint-staged configured once, at the root
  • PostgreSQL 17+ via docker compose; Drizzle + drizzle-kit initialised in the API
  • CI on every PR: two jobs — api (lint → typecheck → test) and web (lint → typecheck → test → build) — plus a shared-contract job

Deliverable: pnpm --filter api dev and pnpm --filter web dev both serve; CI green on main.

Watch for: tooling configured per app drifts within a month — ESLint, Prettier and TS config live at the root and are extended, never copied.

Phase 2 — Walking skeleton (4–6 days)

Goal: one thin slice of real value crossing every layer: DB → API → contract → screen → back.

  • First migration for one entity, applied by the API's release step from day one
  • One feature delivered completely: Express route → service → repository in one transaction, zod schema in packages/shared, React screen consuming it
  • Loading, empty, error and offline states handled on the screen — not skipped
  • Error envelope on the API matched by the client's error handling (a 500 renders a human sentence, not a raw dump)
  • TanStack Query configured on the web side: query keys derived from the shared contract, retry policy, stale times
  • Tests on both sides: supertest against a real Postgres + React Testing Library on the screen; CI green
  • Land both halves in the same week — the contract is proven only when both sides meet

Deliverable: a demoable full-stack feature with one genuinely useful resource, covered by tests.

Watch for: a skeleton that works only with one half running npm run dev against hardcoded localhost is not a skeleton — prove it with the shared config.

Phase 3 — Core capabilities (1–2 weeks)

Goal: the machinery every later feature needs exists once per half, wired together.

  • Auth end to end: register/login on the API — argon2id hashing, JWT access (15 min) via jose, rotating refresh in an httpOnly, SameSite cookie scoped to api.
  • Client auth: silent refresh driven by the cookie, route guards anonymous → authenticated, logout clears the query cache
  • CORS allowlist = app. origin with credentials: true; the cookie/SameSite/CORS matrix re-read once, as a whole (all three must agree)
  • API middleware set: request-ID, pino structured logging, helmet, express-rate-limit on auth, central error envelope
  • Web foundation: UI primitives (Button, Input, Card, List, Skeleton, Toast, Modal) on design tokens, light/dark from day one
  • Form pattern: react-hook-form + zod from packages/shared; 422 field errors land on their fields
  • Pagination/filter/sort convention applied to every list endpoint and mirrored in the client's URLs
  • Error tracking both halves: Sentry with release tags; requireAuth/ownership helpers on the API, hidden-vs-enforced remembered on the web

Deliverable: new features become mostly composition on both sides.

Watch for: the access token in the client's memory and the refresh cookie are not two auth systems — one flow, one logout, tested together.

Phase 4 — Feature build-out (weeks, variable)

Goal: the remaining product features, each as a vertical slice through the whole stack.

  • Feature per slice, vertical not horizontal: schema → migration → route → service → shared schema → screen, merged together
  • Optimistic updates only where a rollback is implementable; everything else waits for the server
  • File uploads presigned to object storage (S3/R2) — the API authorizes, the client streams directly
  • Realwhere needed: SSE first (one-way), WebSocket only when bidirectional, reconnect with backoff
  • Third-party integrations behind typed clients on the API; webhook endpoints signature-verified and replay-protected
  • Lists complete everywhere: pagination/infinite scroll + prefetch, empty states, a11y labels written as the UI is written
  • Route-level code splitting on the web; OpenAPI generated from the shared schemas and read as a consumer would

Deliverable: feature-complete against the Phase 0 spec.

Phase 5 — Quality (3–5 days + ongoing)

Goal: confidence that a change in one half does not silently break the other.

  • Unit tests where logic lives: API services/domain (Vitest), client hooks/formatters (Vitest)
  • Endpoint tests (supertest) on a real database — mocking your own ORM proves nothing
  • Component tests on critical screens with Testing Library (user-facing queries only)
  • Playwright E2E across both halves: onboarding → auth → the money path, against staging — this is the test only a PERN repo can own
  • Contract tests: shared-schema/OpenAPI snapshot diffed in CI — a breaking change fails the PR that introduces it
  • Budgets recorded and enforced: API p95 latency + RPS ceiling (autocannon); web initial JS ≤ 200 KB gzipped, Lighthouse ≥ 90, no layout shift on the money path
  • N+1 audit on list endpoints; pool max sized against Postgres max_connections × replicas

Deliverable: green suite in CI + recorded performance numbers for both halves.

Phase 6 — Hardening (3–5 days)

Goal: safe to hand to strangers.

  • Security pass on the API: OWASP ASVS shortlist — injection (parameterized/ORM only), mass assignment (explicit serializers — never res.json(req.body)), authZ matrix, rate limits
  • Security pass on the web: XSS review (no dangerouslySetInnerHTML on untrusted input), CSP with the api. origin allowlisted, error-boundary coverage
  • Secrets: server env only on the API; nothing but VITE_* public values in the bundle — grep the build output for the strings
  • Cookies reviewed as a set: httpOnly, Secure, SameSite, Domain, Path
  • Logs scrubbed of tokens/PII both sides; Sentry receiving errors with release tags
  • Dependency vulnerability scan in CI for the whole workspace; Renovate on

Deliverable: a passed checklist and live error dashboards for both halves.

Phase 7 — Release (3–7 days)

Goal: both halves serving production traffic behind HTTPS, deployable independently.

  • API container: production Dockerfile — multi-stage, non-root, pinned Node LTS base, dist-only runtime
  • Web artifact: static build to a hosting platform with preview deployment per PR (Vercel/Netlify/Cloudflare Pages — default to what the team knows)
  • DNS live as planned in Phase 0: app. + api., TLS both, cookie Domain/SameSite verified in the browser, not just in config
  • Migrations run as the API's release step, expand-contract by policy — one release of backward compatibility so the halves never lock-step
  • Deploy-order rule written down: the API is backward compatible with the previous client release; either half can ship alone
  • Graceful shutdown rehearsed on the API: SIGTERM → stop accepting → drain in-flight → pool.end()
  • Staging mirrors prod config; the Playwright money-path smoke runs against staging before promote and against prod after
  • Rollback rehearsed both ways: previous API image (+ migrate-forward policy) and previous web build — instant

Deliverable: 1.0 of both halves serving real traffic; rollback tested for each.

Phase 8 — Operate & iterate (forever, ~1 h/week)

Goal: the system gets better instead of older.

  • Weekly triage: Sentry issues both halves, API error rates + p95 + slow queries (pg_stat_statements) + pool saturation, Web Vitals RUM (INP/LCP)
  • Contract discipline stays: schema changes ship in the same PR across apps/api, apps/web, packages/shared
  • Dependency + Node LTS upgrades on a cadence (Renovate weekly; majors in a branch)
  • Quarterly: re-run the security checklist on both halves, revisit ADRs, rotate credentials
  • Monthly glance: DB size, transfer, idle replicas, bundle size trend

Rules that keep this roadmap honest

  1. Walking skeleton before features. One thin slice crossing DB → API → screen beats five half-built layers on either side.
  2. No phase skips. Tests after Phase 5 are cheap; tests retrofitted in Phase 7 are not.
  3. Deliverables are binary. Each phase ends with something that exists or does not.
  4. Cut scope, not structure. A smaller feature set in a sound codebase ships; the reverse rarely does.
  5. Re-estimate at each phase boundary. The roadmap is a living document — update it as reality diverges.

Express API · React client · Next.js alternative

Built with LogoFlowershow