Type‑Safe Incremental Build Pipelines with ESBuild, Turborepo, and TypeScript: Fast, Reliable CI/CD for Large‑Scale Next.js Apps
Introduction
Large JavaScript/Next.js monorepos often suffer from slow CI pipelines and hard‑to‑track type regressions.
When a change touches only a handful of components, the default next build or npm run build still rebuilds the whole repository, wasting minutes (or even hours) of CI resources.
This article shows how to:
- Guarantee type safety across every incremental step using TypeScript project references.
- Leverage ESBuild’s ultra‑fast transpilation as a drop‑in for the TypeScript compiler in non‑type‑checking stages.
- Orchestrate the work graph with Turborepo, so only the affected packages are rebuilt and re‑tested.
The result is a pipeline that:
- Cuts CI build times by 30‑70 % on a 30‑package monorepo.
- Prevents “type‑only” changes from slipping through because the type‑checking stage runs once per commit, not per package.
- Keeps the developer experience fast with local
npm run devstill using Vite/Next.js hot‑module reloading.
Note – The patterns below are deliberately tool‑agnostic beyond the three core choices (ESBuild, Turborepo, TypeScript). You can replace Next.js with Remix, Astro, or plain React without breaking the pipeline.
1. Architecture Overview
┌─────────────────────┐
│ GitHub Actions / │
│ GitLab CI │
│ (pipeline) │
└───────┬─────────────┘
│
▼
┌─────────────────────┐
│ Turborepo (turborepo│
│ run) │
│ ├─ type-check │ ← runs once per commit
│ ├─ build (ESBuild) │ ← incremental per pkg
│ └─ test │ ← cacheable per pkg
└───────┬─────────────┘
│
▼
┌─────────────────────┐
│ Packages (apps/ │
│ libs) │
│ ├─ tsconfig.json │ ← project refs
│ └─ src/… │
└─────────────────────┘
- Type‑checking (
tsc --noEmit) runs once at the root, guaranteeing that the whole type graph is consistent. - Build uses ESBuild in each package, consuming the already‑checked
.d.tsoutput from the type‑checking step. - Turborepo tracks file‑system dependencies (via
package.json'sdependenciesanddevDependenciesas well astsconfig.jsonreferences) and only rebuilds packages whose inputs changed.
2. Setting Up a Type‑Safe Monorepo
2.1 Directory Layout
/repo
├─ apps/
│ ├─ web/ # Next.js app
│ └─ admin/ # Another Next.js app
├─ packages/
│ ├─ ui/ # React component library
│ ├─ utils/ # Shared TS utilities
│ └─ config/ # Shared ESLint/Prettier config
└─ tsconfig.base.json
2.2 Base tsconfig
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true, // root never emits JS
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@ui/*": ["packages/ui/src/*"],
"@utils/*": ["packages/utils/src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
2.3 Package‑Level tsconfig.json
// apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"noEmit": false, // this package *does* emit
"emitDeclarationOnly": true
},
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/utils" }
],
"include": ["src"]
}
Each package adds a references array pointing at its direct dependencies. Turborepo reads these references automatically (via the --since flag or its internal file‑watcher) to construct the DAG.
3. One‑Time Type‑Checking Stage
Create a root script that runs the TypeScript compiler once:
// package.json (root)
{
"scripts": {
"type-check": "tsc -b --pretty"
}
}
-b (build mode) respects references. The command:
- Traverses the whole graph.
- Emits only declaration files (
.d.ts) into each package’sdistfolder. - Fails fast if any type error exists anywhere in the repo.
Because this step produces the definitive type artifacts, downstream build steps can safely skip type checking.
4. Fast Incremental Builds with ESBuild
4.1 Why ESBuild?
- Speed – ~10 × faster than
tscfor transpilation. - Built‑in JSX – perfect for Next.js/React.
- Tree‑shaking – respects
import/exportstatements out of the box.
4.2 ESBuild Wrapper Script
// scripts/build.js
const { build } = require('esbuild');
const path = require('path');
const pkg = require('../package.json'); // resolves to current package
(async () => {
const entry = path.resolve('src/index.tsx'); // adapt per pkg
await build({
entryPoints: [entry],
outdir: 'dist',
bundle: true,
platform: 'node', // 'browser' for UI libs
format: 'esm',
sourcemap: true,
target: ['es2022'],
tsconfig: 'tsconfig.json',
// Preserve the .d.ts files produced by the type‑check step
declaration: false,
// Incremental flag enables ESBuild's persistent process cache
incremental: true,
});
})();
Add it to each package’s package.json:
{
"scripts": {
"build": "node ../../scripts/build.js"
}
}
When Turborepo runs build, it spawns a single persistent ESBuild process per worker, dramatically reducing cold‑start costs.
4.3 Source‑Map Integration for CI
CI systems can upload source maps to Sentry or similar services:
esbuild ... --sourcemap=external
# then:
curl -F file=@dist/*.js.map https://sentry.io/api/0/projects/...
Because ESBuild emits source maps instantly, you get full stack traces without extra steps.
5. Turborepo Configuration
Create a turbo.json at the repo root:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"type-check": {
"outputs": []
},
"build": {
"dependsOn": ["^type-check"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
type-checkhas no outputs – it only validates.builddepends on the type‑check of its own package and all ancestors (^type-check).testruns after the build, reusing the cacheddistfolder.
5.1 Running Locally
# Run the whole pipeline from the root
npx turbo run build test --filter=web
Turborepo will:
- Execute
type-checkonce (shared across all packages). - Detect that only
packages/uichanged, so it rebuildsuiand thenweb.
5.2 CI Example (GitHub Actions)
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 20
cache: npm
- run: npm ci
- name: Cache Turborepo
uses: actions/cache@v3
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: ${{ runner.os }}-turbo-
- name: Run pipeline
run: npx turbo run type-check build test --continue
The cache stores Turborepo’s task hash outputs, allowing subsequent runs to skip unchanged steps instantly.
6. Real‑World Example: A Feature Branch
Imagine a PR that touches only a new UI component in packages/ui.
- GitHub Action checks out the branch and restores the
.turbocache. - Turborepo computes the hash of changed files (
packages/ui/src/NewButton.tsx). - It skips
type-checkbecause the previous commit’s type‑check result is still valid (the hash of all*.tsfiles unchanged). - It runs
buildforuionly, using the persistent ESBuild worker. - Downstream packages (
apps/web) that import@ui/NewButtonare rebuilt because their dependency graph marksuias changed.
The entire CI run completes in ~2 minutes versus ~6 minutes when using a naïve npm run build for every package.
7. Handling Edge Cases
| Situation | Recommended Fix |
|---|---|
Circular package references (e.g., ui imports from utils and vice‑versa) |
Break the cycle with a type‑only import (import type { … } from '@utils') or extract shared types into a dedicated types package. |
| Third‑party libs with no typings | Use declare module 'lib' in a global.d.ts file and add it to tsconfig.base.json include. |
| ESBuild cannot resolve path aliases | Ensure the paths config in tsconfig.base.json matches the alias plugin configuration (e.g., esbuild-plugin-alias). |
| CI cache bloating | Periodically prune old Turborepo caches (npx turbo prune --output .turbo-pruned). |
8. Measuring Success
Add a small “benchmark” job to your CI to surface the impact:
- name: Benchmark build time
run: |
TIMEFORMAT='%R'; time npx turbo run build --filter=web
Typical numbers for a 30‑package monorepo:
| Scenario | Build Time | Cache Hit Rate |
|---|---|---|
| Full rebuild (no cache) | 7 min 30 s | 0 % |
| Incremental after UI change | 2 min 10 s | 68 % |
| Re-run same commit (full cache) | 45 s | 95 % |
9. Extending the Pipeline
- Linting – Add
eslint . --cacheas a separate Turborepo task; it also benefits from the file‑hash cache. - Storybook – Build docs only for packages that changed (
turbo run build-storybook --filter=ui). - Docker images – Use Turborepo’s
--output-logs=hash-onlyto generate a deterministicDockerfilethat copies only thedistfolders needed for the final image.
10. Takeaways
- Separate type‑checking from transpilation – run
tsc --noEmitonce, then let ESBuild handle the fast emit. - Leverage TypeScript project references – they give Turborepo an exact dependency graph without manual configuration.
- Persist ESBuild workers – the
incrementalflag reduces per‑task overhead dramatically. - Cache aggressively – Turborepo’s hash‑based caching, combined with CI caches, turns most PR builds into “seconds‑only” jobs.
By wiring these tools together, you get a type‑safe, incremental, and highly parallelizable CI/CD pipeline that scales from a single Next.js app to a monorepo with dozens of interdependent packages—all while keeping developer feedback loops tight and CI costs low.
Member discussion