5 min read

Type‑Safe Incremental Build Pipelines with ESBuild, Turborepo, and TypeScript: Fast, Reliable CI/CD for Large‑Scale Next.js Apps

Learn how to combine ESBuild, Turborepo, and strict TypeScript configs to create an incremental, type‑safe build pipeline that shrinks CI times for monorepos of Next.js apps.
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 dev still 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.ts output from the type‑checking step.
  • Turborepo tracks file‑system dependencies (via package.json's dependencies and devDependencies as well as tsconfig.json references) 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’s dist folder.
  • 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 tsc for transpilation.
  • Built‑in JSX – perfect for Next.js/React.
  • Tree‑shaking – respects import/export statements 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-check has no outputs – it only validates.
  • build depends on the type‑check of its own package and all ancestors (^type-check).
  • test runs after the build, reusing the cached dist folder.

5.1 Running Locally

# Run the whole pipeline from the root
npx turbo run build test --filter=web

Turborepo will:

  1. Execute type-check once (shared across all packages).
  2. Detect that only packages/ui changed, so it rebuilds ui and then web.

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.

  1. GitHub Action checks out the branch and restores the .turbo cache.
  2. Turborepo computes the hash of changed files (packages/ui/src/NewButton.tsx).
  3. It skips type-check because the previous commit’s type‑check result is still valid (the hash of all *.ts files unchanged).
  4. It runs build for ui only, using the persistent ESBuild worker.
  5. Downstream packages (apps/web) that import @ui/NewButton are rebuilt because their dependency graph marks ui as 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 . --cache as 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-only to generate a deterministic Dockerfile that copies only the dist folders needed for the final image.

10. Takeaways

  1. Separate type‑checking from transpilation – run tsc --noEmit once, then let ESBuild handle the fast emit.
  2. Leverage TypeScript project references – they give Turborepo an exact dependency graph without manual configuration.
  3. Persist ESBuild workers – the incremental flag reduces per‑task overhead dramatically.
  4. 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.