PERN Stack Roadmap
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/Domaindecided 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+ rootengines - Workspace scaffolded:
apps/api,apps/web,packages/shared(TSstrict+noUncheckedIndexedAccesseverywhere) - 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.examplewith{{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 anhttpOnly,SameSitecookie scoped toapi. - Client auth: silent refresh driven by the cookie, route guards
anonymous → authenticated, logout clears the query cache - CORS allowlist =
app.origin withcredentials: 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
maxsized against Postgresmax_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
dangerouslySetInnerHTMLon untrusted input), CSP with theapi.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, cookieDomain/SameSiteverified 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
- Walking skeleton before features. One thin slice crossing DB → API → screen beats five half-built layers on either side.
- No phase skips. Tests after Phase 5 are cheap; tests retrofitted in Phase 7 are not.
- Deliverables are binary. Each phase ends with something that exists or does not.
- Cut scope, not structure. A smaller feature set in a sound codebase ships; the reverse rarely does.
- Re-estimate at each phase boundary. The roadmap is a living document — update it as reality diverges.