Ditching Nx for Turborepo: A Migration Story in One Saturday
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.yamlnatively. Nx does too, but it also layers its own project graph on top viaproject.jsonfiles. We had 36 of them. Turborepo uses your existingpackage.jsonscripts - no sidecar config files. -
No plugin system. This is a feature, not a gap. Every target is an explicit
package.jsonscript. More boilerplate, yes. But when something breaks, you’re debugging atsccommand, not the interaction between@nx/vite,project.jsontargets, and executor options. -
VITE_*wildcard env support. Turborepo’s strict mode lets you declareVITE_*inturbo.jsonand it captures all matching env vars for cache keys. Nx required listing each one explicitly. -
--affectedauto-detects the base ref. In CI, Turborepo readsGITHUB_BASE_REFautomatically. No custom scripts pipingnx affectedoutput 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.
We Switched Build Tools, Deleted 3,000 Lines, and It Took One Saturday
The Irony
Here’s the thing nobody tells you about switching tools: sometimes you’ve already made the switch before.
Back in August 2025, when our codebase was young, we set up Turborepo for task coordination. It lasted less than one day. The same day we set it up, we switched to Nx — a more feature-rich alternative that felt more like a “serious” choice. Turborepo seemed too simple. Nx seemed like the grown-up option.
Eight months later, we switched back to Turborepo.
What Nx Was Actually Doing For Us
Our platform codebase had grown to about 40 packages: several frontend web applications, a dozen backend services, infrastructure code, and shared libraries. Nx was the tool that figured out the order to build things, ran only the affected parts when something changed, and cached results so we didn’t rebuild things unnecessarily.
On paper, everything worked. In practice, we had a growing list of irritations.
Nx has a plugin system that tries to figure out what your packages can do just by looking at their configuration files. In theory, magic. In practice, when the magic doesn’t work, you’re debugging something operating three levels below the surface. We’d had several incidents where builds behaved differently than expected because a plugin was inferring something incorrectly.
We’d also never set up remote caching — where build results get stored so that different machines (like CI servers) can reuse each other’s work. Nx Cloud exists for this, but it’s a paid service that didn’t fit how we’d built our infrastructure. So every CI run was doing everything from scratch.
Over time, Nx had accumulated a lot of configuration for things we weren’t really using. A dependency-checking tool that we’d worked around with 25+ exceptions rather than fixing the underlying issues. Configuration spread across 36 separate files, one per package.
I decided to simplify.
Why Turborepo This Time
Turborepo’s defining quality is that it does less. There’s no plugin system. No configuration files per package. It reads the existing scripts in each package’s standard config file and runs those. Every task is explicit — you can see exactly what command will run.
The other practical reason: someone had built a self-hosted remote cache for Turborepo that we could run in our own AWS infrastructure. No paid third-party service required. (This later caused its own adventure, which is a separate story.)
The Migration: One Saturday, Six Steps
The whole thing happened in one PR. A clean, reviewable progression.
First, we cleaned up. Before touching the build system, we deleted things that had been left behind by a previous change. Three configuration files that weren’t doing anything. A file called project-graph.json — an Nx-generated artifact that had been accidentally committed to version control at 8,620 lines. Total lines deleted before we’d touched a single line of build configuration: 8,770.
Then we added explicit scripts. This is where Turborepo’s “no magic” philosophy created extra work. Nx’s plugins had been silently creating tasks that didn’t need to be written down anywhere. Turborepo requires actual scripts. We went through every package and added the missing ones — mostly one-liners, but they had to exist.
Then we configured Turborepo. One new configuration file at the root. It defines which tasks exist, what order they run in (builds have to happen before the things that depend on them), what output files to cache, and which environment variables affect the cache.
One useful feature: Turborepo has a strict mode where it filters out all environment variables except the ones you explicitly declare. This is great for cache correctness — if an environment variable affects the output, it should affect the cache key. It also means you have to think through which variables matter for each task. We found this out the slightly annoying way when our deployment tasks couldn’t find their AWS credentials.
Then we replaced the commands. About 26 references to the old tool across five files, updated to use the new one. Straightforward.
Then we updated our automated pipelines. Similar substitutions across six workflow files. Turborepo also doesn’t need a startup step or a project discovery phase — it just reads the workspace and goes.
Then we deleted Nx. Thirty-six per-package configuration files. The main Nx configuration file. Ten dependencies. The lockfile shrank by 3,000 lines.
Final count: 3,453 lines deleted, 383 lines added. Deleting 3,000 lines of configuration for a build tool migration is a good sign you made the right call.
The Things That Surprised Us
Strict mode caught us off-guard. The first time CI tried to deploy to AWS after the migration, it couldn’t find credentials. Turborepo’s strict mode had filtered them out because we hadn’t explicitly declared that AWS credentials should pass through. A quick fix once understood, but the failure was silent — the task just couldn’t connect to anything.
TypeScript checking ran out of memory. Running all type checks simultaneously spawned too many processes for our CI servers to handle. The fix was limiting how many run at once. We also tightened up which files each task pays attention to, which helped cache performance too.
The remote cache and very large files don’t mix. CDK — the tool we use to define our cloud infrastructure — produces some very large build outputs. Our two biggest packages produced artifacts of 189MB and 626MB respectively (yes, that’s absurd, and it’s a story for another day). The remote cache server we’d set up hits a 6MB limit on individual transfers, so large artifacts silently fell back to no caching at all, adding 13 minutes to every CI run. We eventually worked around this, but it took longer than the migration itself.
The output directory matters exactly. Turborepo restores cached files to wherever you said they’d be. If your actual build tool writes files to a different location than you declared, the cache appears to work but restores nothing. We found this when staging deployments started intermittently failing — the build was “succeeding” but the cache restoration was writing to an empty directory. A one-line fix, but the failure mode was nasty: green status, no errors, broken deploy.
What We Learned
Delete before you migrate. Over 8,000 lines of our work was just removing things that should have been removed weeks earlier. Dead configuration makes every change harder.
Explicit beats automatic for long-term health. Turborepo requires you to write down what you want. More upfront work, but dramatically easier to debug six months later.
Try tools side by side before committing. We ran both Turborepo and Nx simultaneously for a while, verified they produced the same results, then removed Nx. Any bug after the cutover was unambiguously from the migration, not some pre-existing issue.
Test the full pipeline. Building your code is only one part of the CI process. We tested the deploys and found the credentials problem on the same day we merged, rather than discovering it in production.
The migration cost one Saturday and gave us a simpler build system, fewer configuration files, and — once we got the remote cache working — 13 minutes shaved off every CI run. Not bad.
← Back to posts