Skip to content
Build & Setup
Building a Production-Ready Turborepo Monorepo: Next.js + Express
BuildingBuild & SetupAugust 25, 2026 · 16 min read

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/api gets a Dockerfile and apps/web doesn'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.

text
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:

  1. 1
    Add pnpm-workspace.yaml

    Tells pnpm that apps/* and packages/* are workspaces.

  2. 2
    Pin Node with .nvmrc

    Set it to "24" — Node 24 LTS — so "works on my machine" isn't a Node-version problem.

  3. 3
    Add the usual hygiene files

    .npmrc, .editorconfig, .gitignore.

  4. 4
    Write the root package.json

    "private": true, plus scripts that all just delegate to Turborepo — dev, build, lint, typecheck, test, format each call turbo 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. 1
    packages/typescript-config

    Three tsconfig presets — base.json, nextjs.json, node-library.json — each extending the last. Strict mode, noUncheckedIndexedAccess, the works.

  2. 2
    packages/eslint-config

    Shared ESLint flat config, split into base.js / next.js / node.js, plus eslint-plugin-turbo to catch env vars used without being declared in turbo.json.

  3. 3
    packages/types

    Zod 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

bash
pnpm dlx create-next-app@latest apps/web --typescript --tailwind --eslint --app --src-dir

Then rewire it to the shared packages instead of letting it stand alone:

  • tsconfig.jsonextends: "@repo/typescript-config/nextjs.json"
  • eslint.config.mjs → layers @repo/eslint-config/next on top of eslint-config-next
  • Add @repo/types as a real dependency

Two small application-layer pieces go in on top of the scaffold:

  1. 1
    src/lib/env.ts

    Validates process.env through zod at import time, split into server-only vars and NEXT_PUBLIC_* vars. Missing something? It throws with a message that says exactly what's wrong — not a silent undefined three components later.

  2. 2
    src/lib/api-client.ts

    The only place allowed to call the Express API. Fetches, then parses the response through the shared healthResponseSchema before handing back typed data. Marked server-only so 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.

text
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 /ready

The middleware chain in app.ts, in order, and why each one is there:

  1. 1
    helmet()

    Security headers, free.

  2. 2
    cors({ origin: env.CORS_ORIGINS })

    An explicit allow-list, not *.

  3. 3
    express.json({ limit: "1mb" })

    Bounded body size.

  4. 4
    pino-http

    Structured request logs — deliberately skipping /health//ready so real traffic doesn't get drowned out by orchestrator noise.

  5. 5
    express-rate-limit (100 req/min)

    Applied to everything except health checks, so a load balancer's own probes never trip the limiter.

  6. 6
    The router, then notFoundHandler, then errorHandler

    Always 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 --docker to compute the minimal slice of the monorepo apps/api actually needs, so the image doesn't drag in apps/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 api reproduces the exact production image locally.

One detail easy to get wrong: the build context is the repo root, not apps/apiturbo 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:

json
"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/api uses supertest to hit /health and a bad route through the actual Express app; apps/web tests 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 why
  • getting-started.md — clone to running, plus the troubleshooting actually hit
  • deployment.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. 1
    pnpm turbo run typecheck lint test build

    All green, every workspace.

  2. 2
    pnpm dev

    Both apps booted concurrently; curl localhost:4000/health and curl localhost:3000/ both came back clean.

  3. 3
    docker build -f apps/api/Dockerfile -t minologue-api:local . then docker run

    The container started as nodeusr (not root), and its own HEALTHCHECK reported healthy.

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/ and packages/, plus the root config that ties them together.
  • Build packages/typescript-config, eslint-config, and types before 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/api has a Dockerfile and apps/web doesn'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, types get 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
/health and /ready are 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 what apps/api needs, 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 --fix can'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."
#turborepo#nextjs#express#monorepo#docker#ci

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.