← Back to posts

Ditching Nx for Turborepo: A Migration Story in One Saturday

· 10 min read

The Plot Twist You Didn’t Expect

Here’s the thing nobody tells you about build tool migrations: sometimes you’ve already done it before.

Back in August 2025, when the monorepo was still young, we actually started on Turborepo. Plain pnpm workspaces with Turbo for task orchestration. It lasted less than a day. The same day we set it up, we migrated to Nx. The commit messages tell the story in two lines:

2025-08-06  feat: major monorepo restructure and tooling updates
2025-08-06  chore: migrate from Turbo to NX

Why the immediate switch? At the time, Nx’s plugin system looked like the answer to everything. Auto-inferred targets, project graph visualisation, distributed task execution, Nx Cloud for remote caching. Turbo felt too simple. Nx felt like the grown-up choice.

Eight months later, we were migrating back.

The Itch

Our platform monorepo had been running Nx since that hasty August decision. It grew to around 40 packages - six frontend apps, a dozen backend Lambda services, six CDK domain stacks, and the usual constellation of shared libraries. Nx v22.6.1 was orchestrating builds, tests, typechecks, and CDK operations. On paper, everything worked.

In practice, we had a growing list of annoyances.

The first crack appeared when we migrated linting from ESLint + Prettier to Biome. Nx had a plugin called @nx/enforce-module-boundaries that was supposed to prevent circular imports between packages. It had become a liability: our nx.json carried 25+ exemptions in its depConstraints config, each one a previous developer shrugging and saying “I’ll fix it later.” When we moved to Biome, we couldn’t use that plugin anymore - and nobody missed it. The circular dependencies were still there, but the tool that was meant to prevent them had become a rubber stamp.

The second crack was the plugin system itself. Nx uses plugins like @nx/vite, @nx/vitest, @nx/playwright, and @nx/storybook to auto-infer build targets. When they work, they’re magic - you don’t need explicit scripts, Nx just knows. When they don’t work, you’re debugging configuration inference across three layers of abstraction. And they don’t always work.

The third crack: we never set up remote caching. Nx Cloud existed, but it was a SaaS service that didn’t fit our infrastructure model. We were running a self-hosted CI setup with runners in our own AWS account. Every CI run computed everything from scratch.

We were using Nx for less and less. pnpm workspaces already handled package resolution. Biome handled linting. The plugin magic was more confusing than helpful. The only thing Nx was genuinely doing was task orchestration - running things in dependency order with local caching.

I decided to replace it.

Why Turborepo

The alternative was straightforward: Turborepo. Not because it’s objectively better in every dimension, but because it aligned with how I actually work:

  • First-class pnpm workspace support. Turborepo reads pnpm-workspace.yaml natively. Nx does too, but it also layers its own project graph on top via project.json files. We had 36 of them. Turborepo uses your existing package.json scripts - no sidecar config files.

  • No plugin system. This is a feature, not a gap. Every target is an explicit package.json script. More boilerplate, yes. But when something breaks, you’re debugging a tsc command, not the interaction between @nx/vite, project.json targets, and executor options.

  • VITE_* wildcard env support. Turborepo’s strict mode lets you declare VITE_* in turbo.json and it captures all matching env vars for cache keys. Nx required listing each one explicitly.

  • --affected auto-detects the base ref. In CI, Turborepo reads GITHUB_BASE_REF automatically. No custom scripts piping nx affected output through jq.

  • Self-hosted remote cache. The community had built turborepo-remote-cache - a Lambda-backed S3 cache server we could deploy in our own AWS account. (This later caused us grief, but that’s a separate story.)

The Migration

The whole thing took a Saturday. One PR, six commits, a clean progression that could be reviewed step by step.

Step 1: Clean the Corpses

Before touching the build system, we cleaned up leftovers from the Biome migration. Three ESLint config files were still sitting in the repo, doing nothing. Two ESLint dependencies lingered in the root package.json. And a file called project-graph.json - an Nx-generated cache artifact containing stale ESLint references - was checked into version control at 8,620 lines.

apps/admin-portal/eslint.config.js    |   30 -
apps/developer/eslint.config.js       |   37 -
packages/backend/staff/.eslintrc.json |   14 -
project-graph.json                    | 8620 -

8,770 lines deleted. We hadn’t even started the migration yet.

Step 2: Add the Missing Scripts

This is where Turborepo’s “no magic” philosophy created work. Nx plugins had been auto-inferring targets: if a package had a vite.config.ts, the @nx/vite plugin would create build and serve targets automatically. If it had a vitest.config.ts, @nx/vitest would create test targets. These targets existed only in the Nx project graph - there were no corresponding package.json scripts.

Turborepo runs package.json scripts. No scripts, no tasks.

We went through every package and added explicit scripts. One backend package was missing a typecheck script. All six CDK domain packages needed cdk-synth, cdk-diff, and cdk-deploy scripts - previously these existed only as Nx targets in project.json files.

The scripts were trivial one-liners ("typecheck": "tsc --noEmit", "cdk-synth": "cdk synth"), but they had to exist. This is the tradeoff: more explicit, more predictable, more lines in package.json.

Step 3: Install Turborepo and Create turbo.json

One pnpm add -Dw turbo and a turbo.json that mapped our existing Nx pipeline:

{
  "$schema": "https://turbo.build/schema.json",
  "globalEnv": ["CI", "NODE_OPTIONS"],
  "globalPassThroughEnv": [
    "AWS_ACCESS_KEY_ID",
    "AWS_SECRET_ACCESS_KEY",
    "AWS_SESSION_TOKEN",
    "AWS_REGION",
    "AWS_DEFAULT_REGION"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "cache": true
    },
    "typecheck": {
      "dependsOn": ["^typecheck"],
      "inputs": [
        "**/*.ts", "**/*.tsx", "**/*.cts",
        "**/*.mts", "tsconfig.json", "tsconfig.*.json"
      ],
      "outputs": ["**/.tsbuildinfo"],
      "cache": true
    },
    "test:unit": { "cache": true },
    "test:integration": { "cache": false },
    "e2e": { "cache": false },
    "cdk-synth": {
      "dependsOn": ["^build"],
      "cache": true,
      "outputs": ["infra/cdk.out/**"],
      "env": ["CI_ENVIRONMENT", "CDK_DEPLOY_ACCOUNT", "CDK_DEPLOY_REGION"]
    },
    "lint": {
      "cache": true,
      "inputs": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
    }
  }
}

The beauty of this step: it could coexist with Nx. Both turbo.json and nx.json could exist simultaneously. We ran both tools side by side to verify identical behaviour before cutting over.

Step 4: Replace the Commands

Twenty-six nx command references across five files: root package.json scripts, a PR validation shell script, and several deployment scripts. Simple find-and-replace: nx run-many --target=build became turbo build, nx affected --target=test became turbo test:unit --affected.

Step 5: Update CI Workflows

Thirty substitutions across six GitHub Actions workflow files. Same transformations as the scripts, plus removing Nx-specific setup steps. Turborepo doesn’t need a daemon or a project graph computation step - it just reads your workspace and goes.

Step 6: Delete Nx

The satisfying part.

nx.json                                     |   77 -
apps/admin-portal/project.json              |    7 -
domains/admin/project.json                  |   30 -
domains/developer/project.json              |   30 -
... (36 project.json files)
pnpm-lock.yaml                              | 3127 +-

Thirty-six project.json files deleted. nx.json gone. Ten dependencies removed: nx, @nx/js, @nx/vite, @nx/vitest, @nx/web, @nx/playwright, @nx/storybook, @swc-node/register, @swc/core, @swc/helpers. The lockfile shrank by 3,000 lines.

Total diff: -3,453 lines, +383 lines. A net deletion of over 3,000 lines for a build system migration is a good sign.

The Aftermath

The PR merged. CI went green. We deployed to staging, then production. And then the learning started.

Strict Mode is Strict

Turborepo has a “strict” environment mode that filters out all environment variables not declared in turbo.json. This is great for cache correctness - if an env var affects output, it should affect the cache key. But it also means your CDK deploys fail because AWS_ACCESS_KEY_ID isn’t being passed through.

We hit this the same day. Two follow-up PRs: one to pass AWS credentials through globalPassThroughEnv, another to pass CDK-specific env vars through task-level passThroughEnv. The fix was obvious once you understood the model, but the failure mode was silent - CDK just couldn’t find credentials.

Typecheck OOM

Our self-hosted runners have finite memory. Running turbo typecheck with default concurrency spawned too many tsc processes simultaneously. The runner OOM-killed the job.

Fix: turbo typecheck --concurrency=4. We later scoped the typecheck inputs more tightly and added dependsOn: ["^typecheck"] to ensure dependency order, which improved cache hit rates and reduced peak memory.

The Remote Cache Saga

We deployed the self-hosted remote cache the same day: turborepo-remote-cache on a Lambda behind a Function URL, S3 for storage with a 7-day lifecycle policy. It worked perfectly for small artifacts.

Then CDK synth artifacts hit it. CDK synth produces CloudFormation templates plus bundled Lambda assets. Our two largest apps compressed to 189MB and 626MB respectively (yes, that’s absurdly large for CDK output, and fixing it is a story for another post). Lambda Function URLs have a 6MB payload limit. The cache silently fell back to local-only, adding 13 minutes to every merge.

That’s a whole other blog post. The short version: we eventually had to work around a bug in Turborepo’s own HTTP client to get presigned S3 uploads working.

Outputs Must Match Reality

Turborepo’s cache restores whatever you declare in outputs. If your outputs say dist/** but your Vite config writes to build/, the cache “hits” but restores nothing. The build appears to succeed - the restored dist/ directory is just empty.

We found this when staging deploys started failing intermittently. The fix was trivially simple (align Vite’s outDir with the Turbo outputs), but the failure mode was nasty: cache hit, zero errors, empty deployment.

Lessons

Delete before you migrate. Our ESLint cleanup was 8,770 lines of pure deletion, and it made the rest of the migration cleaner. Dead config files are a tax on every refactor.

Explicit is better than magic. Nx’s plugin system inferred targets from config files. Turborepo requires you to write package.json scripts. The boilerplate is real, but debugging "build": "vite build" is trivial compared to debugging why @nx/vite isn’t detecting your config.

Coexistence enables confidence. We installed Turborepo alongside Nx and verified both produced the same results before removing Nx. If we’d done a big-bang cutover, every post-migration bug would have been ambiguous - was it the migration or a pre-existing issue?

Strict mode is correct but surprising. Turborepo filtering env vars is the right default for cache correctness. But if you’re deploying to AWS from CI, you need to explicitly pass through credentials. Test your deploy pipelines, not just your builds.

Cache output paths are load-bearing. A cache that restores the wrong path is worse than no cache at all. No cache gives you a slow build. Wrong outputs give you a fast green check followed by a broken deploy.

The migration was done in a day, cost us a net negative 3,000 lines of configuration, and gave us a build system that does less magic and causes fewer surprises. The remote cache alone saved us 13+ minutes per merge once we got it working properly. Not bad for a Saturday.