Skip to content
Back to Knowledge Base

Migrating from Turborepo to Nx

Let an AI agent run the migration

Migrate this Turborepo workspace to Nx, following the instructions on the page below.

1. Run `npx nx@latest init` from the workspace root. It detects `turbo.json`, writes an equivalent `nx.json`, adds the `nx` dev dependency, and updates `.gitignore`. Leave `turbo.json` in place until the end.

2. Diff `turbo.json` against the generated `nx.json` and tell me every key that did not carry over. Expect package-specific task entries like `web#build`, per-package `turbo.json` files, `persistent: true`, and task-level `env`, and keep reading past those, since top-level keys such as `ui` map to CLI flags instead of config. Do not stop at a fixed count.

3. Port every `<package>#<task>` entry, and every per-package `turbo.json`, to an array entry under that task's `targetDefaults` key, with `"filter": { "projects": ["<package>"] }`. The unfiltered entry stays first as the baseline. A key set in a filtered entry replaces the baseline value outright rather than merging, and that includes arrays, so re-list the baseline `inputs` in any filtered entry that sets `inputs`. When one rule covers several packages, prefer a directory pattern such as `apps/*` over listing each name, and check that package names actually share a prefix before reaching for a name glob.

4. Set `"continuous": true` on every task that was `persistent: true`.

5. Move each task's `env` list into that target's `inputs` as `{ "env": "VAR_NAME" }` entries. Then add `"sharedGlobals"` to the `inputs` of every target that has an explicit `inputs` array, because `nx init` reaches `sharedGlobals` only through `default` and an explicit array bypasses it, which silently stops `globalEnv` and `globalDependencies` from invalidating those targets. Do not add `passThroughEnv` equivalents, because Nx does not block environment variables at runtime.

6. Verify. Run `nx show project <name>` on a project each filtered entry should hit and on one it should not. Then run `nx run-many -t build lint check-types` twice and confirm the second run is all cache hits. `run-many` exits 0 for a target no project defines, so compare the project count in the summary against `nx show projects`. If `globalDependencies` was set, edit one of those files and confirm the dependent targets miss the cache.

7. Move CI over. Replace `turbo run` calls with `nx affected -t <tasks>`, and tell me whether the workspace should run `nx connect` for remote caching.

8. Only once CI is green: delete `turbo.json` and any package-level `turbo.json`, remove the `turbo` dev dependency, drop `.turbo` from `.gitignore`, and rewrite root scripts from `turbo run <task>` to `nx run-many -t <task>`. Then grep the whole workspace for `turbo` and deal with what is left, including scripts in packages rather than at the root and `eslint-plugin-turbo` in the shared ESLint config.

Report what you changed in `nx.json`. Where a fix belongs in a project's own configuration instead, tell me rather than editing it silently.

Page: https://nx.dev/docs/kb/from-turborepo.md

Migrate this Turborepo workspace to Nx, following the instructions on the page below.

1. Run `npx nx@latest init` from the workspace root. It detects `turbo.json`, writes an equivalent `nx.json`, adds the `nx` dev dependency, and updates `.gitignore`. Leave `turbo.json` in place until the end.

2. Diff `turbo.json` against the generated `nx.json` and tell me every key that did not carry over. Expect package-specific task entries like `web#build`, per-package `turbo.json` files, `persistent: true`, and task-level `env`, and keep reading past those, since top-level keys such as `ui` map to CLI flags instead of config. Do not stop at a fixed count.

3. Port every `<package>#<task>` entry, and every per-package `turbo.json`, to an array entry under that task's `targetDefaults` key, with `"filter": { "projects": ["<package>"] }`. The unfiltered entry stays first as the baseline. A key set in a filtered entry replaces the baseline value outright rather than merging, and that includes arrays, so re-list the baseline `inputs` in any filtered entry that sets `inputs`. When one rule covers several packages, prefer a directory pattern such as `apps/*` over listing each name, and check that package names actually share a prefix before reaching for a name glob.

4. Set `"continuous": true` on every task that was `persistent: true`.

5. Move each task's `env` list into that target's `inputs` as `{ "env": "VAR_NAME" }` entries. Then add `"sharedGlobals"` to the `inputs` of every target that has an explicit `inputs` array, because `nx init` reaches `sharedGlobals` only through `default` and an explicit array bypasses it, which silently stops `globalEnv` and `globalDependencies` from invalidating those targets. Do not add `passThroughEnv` equivalents, because Nx does not block environment variables at runtime.

6. Verify. Run `nx show project <name>` on a project each filtered entry should hit and on one it should not. Then run `nx run-many -t build lint check-types` twice and confirm the second run is all cache hits. `run-many` exits 0 for a target no project defines, so compare the project count in the summary against `nx show projects`. If `globalDependencies` was set, edit one of those files and confirm the dependent targets miss the cache.

7. Move CI over. Replace `turbo run` calls with `nx affected -t <tasks>`, and tell me whether the workspace should run `nx connect` for remote caching.

8. Only once CI is green: delete `turbo.json` and any package-level `turbo.json`, remove the `turbo` dev dependency, drop `.turbo` from `.gitignore`, and rewrite root scripts from `turbo run <task>` to `nx run-many -t <task>`. Then grep the whole workspace for `turbo` and deal with what is left, including scripts in packages rather than at the root and `eslint-plugin-turbo` in the shared ESLint config.

Report what you changed in `nx.json`. Where a fix belongs in a project's own configuration instead, tell me rather than editing it silently.

Page: https://nx.dev/docs/kb/from-turborepo.md

nx init reads your turbo.json and writes the equivalent nx.json. Your package.json scripts stay as they are, and Nx runs them through the package manager you already use.

Nx runs the same scripts, so what you gain is the work Turborepo leaves to you.

First-party plugins for Maven, Gradle, and .NET put those projects in the same graph as your JavaScript packages, and community plugins cover Python, Rust, and Go. Turborepo can wrap any CLI in a package.json script, and an experimental flag teaches it Cargo workspaces, but outside those it builds no dependency graph, so a Go service and the app calling it stay islands.

Turborepo scales past one machine through manual binning, where you assign tasks to runners by hand and rebalance as the workspace grows, while Nx Agents distributes at the task level using historical timings. On one workspace across four machines that was 9m 20s against Turborepo's 19m 18s. Nx Cloud also splits e2e suites by file, re-runs flaky tasks, and runs an agent that diagnoses and fixes broken tasks on CI.

Task sandboxing surfaces the files a task read or wrote without declaring them, and Turborepo has no equivalent. Generators run as code against the project graph and do AST-level transforms rather than filling in templates.

For the full comparison, see Nx vs Turborepo.

From the workspace root:

Terminal window
npx nx@latest init

Nx detects the turbo.json, converts it, and installs:

.gitignore | Nx cache directories
nx.json | The converted turbo.json
package.json | The "nx" dev dependency
<lockfile> |

turbo.json stays where it is and Turborepo keeps working, so you can run both side by side until you remove it in step 8.

Take the turbo.json from the create-turbo starter:

turbo.json
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^lint"]
},
"check-types": {
"dependsOn": ["^check-types"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}

nx init turns it into this:

nx.json
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["{projectRoot}/**/*", "{projectRoot}/.env*"],
"outputs": ["{projectRoot}/.next/**", "!{projectRoot}/.next/cache/**"],
"cache": true
},
"lint": {
"dependsOn": ["^lint"],
"cache": true
},
"check-types": {
"dependsOn": ["^check-types"],
"cache": true
},
"dev": {
"cache": false
}
}
}

Three differences to read closely:

  • Turborepo paths are relative to a package. Nx paths are relative to the workspace root and use {projectRoot} and {workspaceRoot} tokens to say which one you mean, so one entry can reference a workspace-wide file and a per-package file at the same time.
  • Turborepo caches a task unless you opt out, so an empty task entry is a cached task. Nx writes cache for each one.
  • persistent: true on dev is gone. Step 4 puts it back under its Nx name.
  • ui is gone as well, and it is not the only top-level key that ends up as a CLI flag rather than configuration. Check yours against the configuration mapping below.

3. Move package-specific task configuration

Section titled “3. Move package-specific task configuration”

Turborepo lets you override a task for one package, either with a <package>#<task> key in the root turbo.json or with a turbo.json inside the package that extends the root. nx init skips both, so a config like this comes across with only the build entry:

turbo.json
{
"tasks": {
"build": { "dependsOn": ["^build"] },
"web#build": {
"outputs": [".next/**", "!.next/cache/**"]
}
}
}

In Nx, that key takes an array instead, with a base entry and an override scoped by filter:

nx.json
{
"targetDefaults": {
"build": [
{ "dependsOn": ["^build"], "cache": true },
{
"filter": { "projects": ["web"] },
"outputs": ["{projectRoot}/.next/**", "!{projectRoot}/.next/cache/**"]
}
]
}
}

An override replaces the base value for each key it sets, so a filtered entry that sets inputs needs the baseline inputs listed again. The target defaults reference covers the rest.

A per-package turbo.json gets the same treatment. Its task config becomes one more override keyed to that project, and the file itself goes away in step 8.

When one rule covers several packages, scope by directory rather than listing names, as in "projects": ["apps/*"]. filter.projects also takes name globs, tags that you set in each project's own configuration, and negation with !.

Confirm the scoping in both directions before moving on. Check a project the filter should hit, then one it should not:

Terminal window
nx show project web
nx show project docs

persistent: true does not survive the conversion, and Nx calls the same thing continuous:

nx.json
{
"targetDefaults": {
"dev": {
"cache": false,
"continuous": true
}
}
}

A task that depends on a continuous task starts as soon as the server it needs is up, rather than waiting for a process that never exits. That is how you express "run the e2e tests against the dev server" as a task dependency.

5. Carry over task-level environment variables

Section titled “5. Carry over task-level environment variables”

A task's own env list does not convert, so add those to the target's inputs:

nx.json
{
"targetDefaults": {
"build": {
"inputs": [
"{projectRoot}/**/*",
"{projectRoot}/.env*",
{ "env": "API_URL" }
]
}
}
}

Declaring an environment variable this way affects the cache only. Change API_URL and the build reruns. See environment variables as inputs for the wildcard and runtime forms.

Turborepo's passThroughEnv and globalPassThroughEnv have no counterpart, because Nx never blocked environment variables from reaching your tasks in the first place. Delete them.

Run everything twice:

Terminal window
nx run-many -t build lint check-types

The first run populates the cache and the second should report every task as a cache hit. If a task misses on the second run, its inputs include something that changes between runs.

run-many exits 0 for a target that no project defines, so a typo in a task name reports success without running anything. The run summary reports how many projects it covered, and comparing that against nx show projects catches the case where a target name silently matched nothing.

Open the project graph and check that the dependencies between your packages are the ones you expect:

Terminal window
nx graph

Replace the turbo run calls in your CI config with the Nx equivalents, and run only what your change touched:

Terminal window
nx affected -t build lint check-types

Then connect the workspace so CI and your team share one cache:

Terminal window
nx connect

Remote caching on Nx Cloud is the direct replacement for Turborepo's Remote Cache. Distributing tasks across agents and splitting e2e runs by file have no Turborepo equivalent, and they speed up the runs a cache cannot help with, such as a change to a shared package. The CI setup guide walks through a full pipeline.

Once CI is green on Nx, delete the root turbo.json and any package-level ones, remove the turbo dev dependency, and drop .turbo from .gitignore. Then point the root scripts at Nx:

package.json
{
"scripts": {
"build": "nx run-many -t build",
"dev": "nx run-many -t dev",
"lint": "nx run-many -t lint",
"check-types": "nx run-many -t check-types"
}
}

The references that break are the ones below the root, so search the workspace before you commit:

Terminal window
grep -rn turbo --include=package.json --include="*.js" --include="*.mjs" . | grep -v node_modules

For example, a create-turbo workspace ships two of these. packages/ui runs turbo gen react-component, a script in a package rather than at the root, and it fails the moment the dependency goes. Port it to a generator. The shared ESLint config depends on eslint-plugin-turbo for a rule that reads the turbo.json you deleted, so drop both.

The migration above keeps every task defined by a package.json script. That is how Turborepo works, and it runs an Nx workspace fine.

Nx can also derive tasks from the tools you already configured. Add @nx/vite and a package with a vite.config.ts gets a build target whose inputs and outputs are read from that config, with no script and no targetDefaults entry to maintain. The same applies to Jest, Playwright, ESLint, Cypress, Gradle, and others. That removes the class of cache bug where a tool's output directory changes and the config that lists it does not.

Adopt this per tool, after the migration is done. See inferred tasks.

Global configuration:

Turborepo PropertyNx Equivalent
cacheDirSet in cacheDirectory
daemonUse NX_DAEMON=false or set useDaemonProcess: false in nx.json
envModeNx core does not block any environment variables. See Loading Environment Variables
globalDependenciesAdd to the sharedGlobals namedInput
globalEnvAdd to the sharedGlobals namedInput as an env input
globalPassThroughEnvN/A. See Loading Environment Variables
remoteCacheSee remote caching
uiNx will intelligently pick the most appropriate terminal output style, but it can be overridden with --output-style

Task configuration:

Turborepo PropertyNx Equivalent
extendsN/A. Projects always extend targetDefaults from nx.json
dependsOnSame syntax
envDefine env inputs
passThroughEnvN/A. See Loading Environment Variables
outputsSimilar syntax
cacheSimilar syntax
inputsSimilar syntax
outputLogsUse --output-style
persistentUse continuous
interactiveN/A. Tasks marked continuous can accept stdin automatically.
<package>#<task>An array entry under that task's targetDefaults key with a filter
Turborepo CommandNx Equivalent
turbo run test lint buildnx run-many -t test lint build
turbo run build --affectednx affected -t build
turbo devtoolsnx graph for full interactive experience, also available in Nx Console
--cache-dirSet in nx.json under cacheDirectory
--concurrency--parallel
--continueUse --nx-bail with the inverse value
--cpuprofileUse NX_PROFILE=profile.json
--cwdAvailable in run-commands executor
--daemonUse NX_DAEMON=false or set useDaemonProcess: false
--dry-runUse nx show target <project>:<target> --inputs --outputs to preview inputs and outputs (available since 22.6.0+)
--env-modeSee Loading Environment Variables
--filterUse lots of advanced project matcher syntax like -p admin-* or -p tag:api-*
--forcenx reset and then run the command again
--framework-inferenceN/A. Nx plugins infer tasks automatically as a first class feature
--global-depsUse the sharedGlobals namedInput. Nx is far more flexible with composable namedInputs
--graphSimilar syntax or nx graph for full interactive experience
--heapN/A. Use --verbose
--ignoreUse .nxignore or .gitignore
--log-orderUse --output-style
--no-cacheUse --skip-nx-cache
--output-logsUse --output-style
--onlyN/A
--parallelN/A
--preflightN/A
--summarizeN/A
--tokenSet Nx Cloud CI Access Token
--teamSee --token for Nx Cloud workspace selection
--traceN/A. Use --verbose
--verbosityUse --verbose
turbo genUse nx generate
turbo loginnx login - Create an Nx Cloud account
turbo linknx connect - Connect a workspace to an Nx Cloud account

For a complete list of Nx commands and options, see the Nx CLI documentation.

Last updated: