
Building a Production-Ready Turborepo Monorepo: Next.js + Express
A step-by-step walkthrough of setting up a Next.js + Express monorepo with shared types, Docker, and CI wired in from day one — including the two places the toolchain fought back.
Written by
A Lazy Entrepreneur
Every new product starts with the same annoying question: where does the code live? One repo or two? How does the frontend talk to the backend without the two silently drifting apart? How do you deploy a Next.js app and an Express API that want completely different hosting models — without the setup turning into a snowflake nobody else can reproduce?
This is a step-by-step walkthrough of exactly how we set up minologue.com as a Turborepo monorepo: a Next.js frontend and an Express API, sharing config and typed contracts, with Docker, CI, and docs wired in from day one — not bolted on later. Every step below is what we actually ran, in order, including the two places the toolchain fought back.
Before Step 1: three decisions, locked in
- pnpm as the package manager — fast, strict, and the de facto standard for Turborepo workspaces.
- The Express API deploys as a Docker container, not as Vercel Functions — that's why
apps/apigets aDockerfileandapps/webdoesn't; they deploy to genuinely different places. - No database yet. The scaffold stays storage-agnostic on purpose — adding one later is a package, not a rewrite.
Locking these in first meant every step below had one obvious answer instead of five plausible ones. With that settled, here's the order it actually got built in.
Step 1 — Lay Out the Skeleton
Before any code, the folder structure — because every later step assumes it's already there.
apps/
web/ # Next.js
api/ # Express
packages/
typescript-config/
eslint-config/
types/
docs/apps/* are the things that actually deploy. packages/* are internal, unpublished, "private": true — glue, not products.
Then, at the root:
- 1Add
pnpm-workspace.yamlTells pnpm that
apps/*andpackages/*are workspaces. - 2Pin Node with
.nvmrcSet it to "24" — Node 24 LTS — so "works on my machine" isn't a Node-version problem.
- 3Add the usual hygiene files
.npmrc,.editorconfig,.gitignore. - 4Write the root
package.json"private": true, plus scripts that all just delegate to Turborepo —dev,build,lint,typecheck,test,formateach callturbo run <script>.
Why this first: every later step assumes this scaffold exists. Get the workspace glob wrong and nothing after it resolves correctly.
Step 2 — Build the Shared Packages First
This is the part that's easy to skip and expensive to retrofit: build the shared packages before either app, so the apps are wired to them from birth instead of refactored into them later.
- 1
packages/typescript-configThree tsconfig presets —
base.json,nextjs.json,node-library.json— each extending the last. Strict mode,noUncheckedIndexedAccess, the works. - 2
packages/eslint-configShared ESLint flat config, split into
base.js/next.js/node.js, pluseslint-plugin-turboto catch env vars used without being declared inturbo.json. - 3
packages/typesZod schemas (
healthResponseSchema,apiErrorSchema) with inferred TypeScript types sitting right next to them.
That last one is the actual point of the whole exercise: one schema, imported by both apps. Change a response shape in packages/types, and the frontend fails to typecheck the moment it's out of sync — not the moment a user hits a broken page in production.
Step 3 — Scaffold the Next.js App
pnpm dlx create-next-app@latest apps/web --typescript --tailwind --eslint --app --src-dirThen rewire it to the shared packages instead of letting it stand alone:
tsconfig.json→extends: "@repo/typescript-config/nextjs.json"eslint.config.mjs→ layers@repo/eslint-config/nexton top ofeslint-config-next- Add
@repo/typesas a real dependency
Two small application-layer pieces go in on top of the scaffold:
- 1
src/lib/env.tsValidates
process.envthrough zod at import time, split into server-only vars andNEXT_PUBLIC_*vars. Missing something? It throws with a message that says exactly what's wrong — not a silentundefinedthree components later. - 2
src/lib/api-client.tsThe only place allowed to call the Express API. Fetches, then parses the response through the shared
healthResponseSchemabefore handing back typed data. Markedserver-onlyso it can't leak into a client bundle.
Why bother with a wrapper for one endpoint: it's not about the one endpoint. It's about establishing the pattern before there are ten endpoints and no one remembers to validate the eleventh.
Step 4 — Build the Express API by Hand
No boilerplate generator here — apps/api gets hand-built so every piece is intentional.
src/
index.ts # entrypoint: listen, graceful shutdown, SIGTERM/SIGINT
app.ts # the Express factory + middleware chain
config/env.ts # zod-validated env, fails fast on boot
lib/logger.ts # pino, pretty in dev, structured JSON in prod
lib/http-error.ts # HttpError / NotFoundError for the error handler
middleware/error-handler.ts
routes/health.ts # GET /health, GET /readyThe middleware chain in app.ts, in order, and why each one is there:
- 1
helmet()Security headers, free.
- 2
cors({ origin: env.CORS_ORIGINS })An explicit allow-list, not
*. - 3
express.json({ limit: "1mb" })Bounded body size.
- 4
pino-httpStructured request logs — deliberately skipping
/health//readyso real traffic doesn't get drowned out by orchestrator noise. - 5
express-rate-limit(100 req/min)Applied to everything except health checks, so a load balancer's own probes never trip the limiter.
- 6The router, then
notFoundHandler, thenerrorHandlerAlways last.
The error handler is the piece that makes the rest of the app boring to write: throw an HttpError(404, "message", "CODE") anywhere and it becomes a correctly-shaped, correctly-status-coded JSON response. Throw anything else, and it becomes a logged 500 with the stack trace kept out of the response in production.
GET /health and GET /ready exist as two separate endpoints on purpose — liveness ("is the process up") and readiness ("can it serve traffic") are different questions. They're identical today because there's no database to ping yet; that's precisely the seam where a future DB check belongs.
Step 5 — Dockerize the API
Since the API deploys as a container, not to Vercel, the Dockerfile needed to actually be good.
Three stages, one image
- `pruner` — runs
turbo prune api --dockerto compute the minimal slice of the monorepoapps/apiactually needs, so the image doesn't drag inapps/web's dependencies. - `builder` — installs and compiles.
- `runner` — copies over only the compiled output.
- Non-root. The final image runs as
nodeusr, not root. - `HEALTHCHECK` built in, hitting
/health— so any container host (Railway, Fly, ECS, a bare VPS) gets liveness monitoring without extra config. - A root `docker-compose.yml`, so
docker compose up --build apireproduces the exact production image locally.
One detail easy to get wrong: the build context is the repo root, not apps/api — turbo prune needs to see the whole workspace to know what to keep.
Step 6 — Wire It Together with Turborepo
turbo.json defines the task graph:
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] },
"dev": { "cache": false, "persistent": true },
"lint": { "dependsOn": ["^build"] },
"typecheck": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["^build"] }dependsOn: ["^build"] means "build my dependencies first" — so packages/types builds before apps/api tries to typecheck against it. Run pnpm build twice with no changes, and the second run is near-instant, replayed entirely from cache.
Step 7 — Add the Dev Tooling
- Prettier at the root — one config for the whole repo.
- Husky + lint-staged — a pre-commit hook that formats staged files automatically.
- Vitest in both apps, with real tests, not placeholders:
apps/apiusessupertestto hit/healthand a bad route through the actual Express app;apps/webtests a pure utility function.
Step 8 — Set Up CI
.github/workflows/ci.yml runs the same commands a developer runs locally — pnpm turbo run lint typecheck test build — plus a second job that builds the Docker image and curls its /health endpoint before merge.
If it's broken, it's caught before it's anyone else's problem.
Step 9 — Write the Docs
A docs/ folder with three files, each answering one question:
architecture.md— what's here, and whygetting-started.md— clone to running, plus the troubleshooting actually hitdeployment.md— how each app actually ships
The root README.md stays short on purpose — a quick-start and a table, then a link into docs/ for everything else.
Where the Toolchain Pushed Back
This is the part worth remembering, because it's a good illustration of what "production-ready" actually means in practice: not assuming latest is always safe, and catching it with real verification instead of shipping it.
TypeScript 7.0
TypeScript 7.0 had just shipped — a from-scratch, Go-ported compiler. typescript-eslint didn't support it yet (a known, tracked gap). Running pnpm turbo run lint surfaced it immediately: typescript-eslint does not support TS 7.0.
The fix: pin the whole repo to typescript@^6.0.3 — the newest version the full toolchain actually agrees on — rather than let latest quietly break linting.
ESLint 10
ESLint 10 had the same story from a different angle: eslint-config-next (and the eslint-plugin-react it pulls in) hasn't caught up to ESLint 10 yet, so apps/web runs ESLint 9.x while apps/api/packages/* run ESLint 10.x.
That's fine inside each workspace's own pnpm lint — pnpm resolves each workspace's own version correctly. It broke, specifically, in the pre-commit hook, because a single root-level eslint --fix can only resolve one hoisted version, and it picked the wrong one for apps/web.
The fix: the pre-commit hook only runs Prettier; full ESLint enforcement happens via pnpm lint and in CI, where each workspace runs its own correctly-resolved version. Documented in docs/getting-started.md so it reads as a deliberate choice, not a mystery.
Neither of these was found by guessing — they were found by actually running pnpm turbo run typecheck lint test build and reading the failure.
Verify Everything, End to End
Before calling it done, every layer got exercised for real, not just typechecked.
- 1
pnpm turbo run typecheck lint test buildAll green, every workspace.
- 2
pnpm devBoth apps booted concurrently;
curl localhost:4000/healthandcurl localhost:3000/both came back clean. - 3
docker build -f apps/api/Dockerfile -t minologue-api:local .thendocker runThe container started as
nodeusr(not root), and its ownHEALTHCHECKreportedhealthy.
Overview: Why This Setup Wins
Zoom out, and the obvious alternative is two separate repos, or one repo with no shared package and duplicated types. Both work, until they don't.
- Two repos — every API shape change is a manual, unenforced synchronization between two codebases. Someone always forgets, and it's always found in production.
- One repo, no shared types — the same class of bug, just with extra steps: the "sync" is a human copy-pasting a type definition, occasionally correctly.
- This setup — the shared contract becomes a compiler error instead of a Slack message. Turborepo's caching keeps the CI/local cost of "one monorepo instead of two" close to zero. And because Docker and Vercel were chosen deliberately per app instead of forced into one shape, each app deploys the way it actually should, not the way the other app requires.
The whole build, in one line each
- Lay out
apps/andpackages/, plus the root config that ties them together. - Build
packages/typescript-config,eslint-config, andtypesbefore either app touches them. - Scaffold Next.js, then wire it to the shared config and add an env-validated, schema-checked API client.
- Hand-build the Express API: entrypoint, middleware chain, error handler, health/ready routes.
- Dockerize the API with a pruned, non-root, three-stage build.
- Wire the task graph with Turborepo so builds cache and dependencies build in order.
- Add Prettier, Husky/lint-staged, and real Vitest tests.
- Mirror it all in CI, plus a Docker build-and-health-check job.
- Write
docs/so the next person doesn't have to reverse-engineer any of it.
The scaffold is deliberately stopped at "storage-agnostic, deployable, documented" — not "opinionated about a database" or "full of speculative features." The next real steps are vercel link from apps/web, picking a container host for apps/api, and adding the first real feature — which, per docs/architecture.md, starts with a schema in packages/types, not a route.
Key Insight
A monorepo isn't the point. A shared, enforced contract between two apps that deploy completely differently — that's the point. Everything else here just exists to make that one thing hold.
Study Notes
Before you move on
- Package manager
- pnpm — fast, strict, the de facto standard for Turborepo workspaces.
- API deploy target
- Docker container, not Vercel Functions — why
apps/apihas aDockerfileandapps/webdoesn't. - Database
- None yet, on purpose — storage-agnostic scaffold; adding one later is a package, not a rewrite.
- Shared packages first
typescript-config,eslint-config,typesget built before either app, so the apps are wired to them from birth.- The actual point
packages/types— one zod schema imported by both apps, so a shape mismatch is a compile error, not a production bug.- Express middleware order
- helmet → cors → JSON body limit → pino-http (skipping health routes) → rate limit (skipping health routes) → router → 404 → error handler, always last.
- Liveness vs. readiness
/healthand/readyare separate endpoints on purpose — identical today, but that's exactly where a future DB check belongs.- Docker build stages
- pruner (
turbo prune) → builder → runner — keeps the final image to just whatapps/apineeds, running as a non-root user. - Turborepo's `dependsOn: ["^build"]`
- Builds dependencies first, and caches the result — a repeat build with no changes replays instantly.
- Pre-commit hook scope
- Prettier only, not ESLint — a root-level
eslint --fixcan't resolve two different hoisted ESLint majors correctly across workspaces. - What's deliberately missing
- A database and any speculative features — the scaffold stops at "storage-agnostic, deployable, documented."
unskilled.pro
Have a thought on this one?
I read every reply. Tell me what you think, what I got wrong, or what you'd want me to figure out next.