What moving an Nx project between monorepos involves, and when to do it
Moving an Nx project from one monorepo to another is two separate jobs that tend to get blurred together. The first is carrying Git history: the commits that touched the project's files have to be rewritten so they apply cleanly under the project's new path in the target repository. The second is rewiring the Nx project graph: the project's configuration, its targets, its path aliases and its dependencies on other projects all have to resolve inside a workspace that was never set up for them.
Treat these as distinct steps with distinct checks. A move can preserve every commit and still produce a project that fails to build. It can also build cleanly and quietly drop the blame history your team relies on during incident review. If you verify each step on its own, you know which half broke.
Teams usually split for three reasons. Ownership has drifted, and one group now maintains code that lives in another group's repository. Coupling has dropped, so the project rarely changes alongside its neighbours. CI scope has grown, and affected-project calculations keep pulling in work that has nothing to do with the change. This guide is for backend and platform engineers who own workspace structure and will deal with the consequences.
Don't split when:
- Services depend on atomic cross-project PRs. After the split, one coordinated change becomes several releases that have to land in the right order.
- The two workspaces are far apart in Nx or tooling versions. Closing that gap is a migration, not a move.
- The project's history is mostly changes to root-level config. Those commits won't come with the project in any meaningful form.
Map what has to move before running anything
Start with nx graph and focus it on the project you're moving. You need two lists: dependencies (what the project imports) and dependents (what imports it). Dependents matter because they will break in the source workspace once the project is gone. The graph only shows what Nx can infer, so also check implicitDependencies in the project's configuration. Those edges are declared by hand, often for reasons nobody remembers.
Next, read the paths block in the root tsconfig.base.json. Every alias the project imports through needs a matching entry in the target workspace, or it needs to be rewritten. Then look at root-level config the project inherits without declaring it: targetDefaults and namedInputs in nx.json, root ESLint and test runner presets, and shared Babel or SWC settings. None of these appear in the graph.
Each shared library you find needs a decision:
| Option | What you do | Cost |
|---|---|---|
| Duplicate | Copy the lib into the target workspace | Drift: fixes land in one copy and not the other |
| Publish | Release it as a versioned package both repos consume | Versioning, a release pipeline, and slower iteration on the lib |
| Move it too | Bring the lib along with its history | Each lib has its own dependencies, so you can end up pulling in half the source workspace |
Finally, compare the versions of Nx, its plugins, TypeScript, and the test runner across both workspaces. If they differ by more than a minor release, aligning them is usually the bigger job. Transferring history takes an afternoon. A version upgrade can take a sprint, so find the gap before you commit to a date.
#!/usr/bin/env bash
# Run from the root of the SOURCE workspace. Read-only: nothing is moved.
set -euo pipefail
PROJECT=billing-api # project you plan to move
TARGET_WS=../platform-workspace # root of the target workspace
# Interactive graph limited to the project and its neighbours
npx nx graph --focus="$PROJECT"
# Export the full project graph as JSON so it can be queried
npx nx graph --file=graph.json
echo "== Dependencies (what $PROJECT imports) =="
jq -r --arg p "$PROJECT" \
'.graph.dependencies[$p][] | "\(.target) [\(.type)]"' graph.json
echo "== Dependents (what breaks here once $PROJECT is gone) =="
jq -r --arg p "$PROJECT" \
'[.graph.dependencies | to_entries[]
| select(any(.value[]; .target == $p)) | .key] | unique[]' graph.json
echo "== Hand-declared implicitDependencies =="
npx nx show project "$PROJECT" --json | jq '.implicitDependencies // []'
# Aliases that need a matching entry in the target workspace or a rewrite.
# jq fails if the file contains comments; strip them first in that case.
echo "== Root path aliases =="
jq '.compilerOptions.paths' tsconfig.base.json
# Root config the project inherits without declaring it
echo "== nx.json targetDefaults / namedInputs =="
jq '{targetDefaults, namedInputs}' nx.json
# Nx, plugin, TypeScript and tool versions on both sides; look for gaps
npx nx report > source-report.txt
(cd "$TARGET_WS" && npx nx report) > target-report.txt
diff source-report.txt target-report.txt || true
A read-only audit script that lists a project's dependencies, dependents, implicit dependencies, root path aliases and inherited nx.json config, then diffs nx report output between the source and target workspaces to expose version gaps before any history is moved.
How history transfer works: nx import vs git filter-repo vs git subtree
nx import
nx import brings a directory from another repository into the current workspace and keeps that directory's commit history. You point it at a source repository, a ref, a source directory, and a destination path. It handles the Git work, places the files, and can detect Nx plugins that the imported code may need. Several jobs are left to you: aligning dependency versions, fixing path aliases in the base tsconfig, reconciling lint and test configuration, and deciding what happens to the source. Options and plugin detection have changed between releases. Read nx import --help in your installed version before you rely on any behaviour described here.
The plain Git path
Start with a fresh, throwaway clone of the source. git filter-repo rewrites every commit and refuses to run on a clone that doesn't look fresh, because rewriting a working repository destroys it. Run it with --path to keep only the project directory. Add --path-rename if the target layout differs, so the rewritten history already reflects the new location. In the target workspace, add that clone as a remote, fetch it, and merge with --allow-unrelated-histories, since the two histories share no common ancestor.
git subtree split --prefix ships with Git and needs no extra tool. It is much slower on long histories and follows only one prefix.
nx import |
git filter-repo |
git subtree split |
|
|---|---|---|---|
| Control | Low; the tool chooses steps | High; every path and rename is explicit | Medium |
| Speed | Moderate | Fast | Slow on large histories |
| Multiple historical paths | One source directory per run | Repeat --path for each old location |
One prefix only |
| LFS and submodules | Check your version's behaviour | Pointer files kept; LFS objects pushed separately; submodules stay as gitlinks | Same limits as filter-repo |
| Nx version dependence | High | None | None |
#!/usr/bin/env bash
set -euo pipefail
# 1. Fresh throwaway clone: filter-repo rewrites history and expects a fresh clone.
git clone /tmp/billing-extract
cd /tmp/billing-extract
# 2. Keep only the project directory, including an older location it lived in,
# and rename both to the target workspace layout in the same pass.
git filter-repo \
--path packages/billing/ \
--path src/billing/ \
--path-rename packages/billing/:libs/billing/ \
--path-rename src/billing/:libs/billing/
# 3. In the target workspace, pull the rewritten history in as a remote.
cd ~/work/acme-monorepo
git switch -c import-billing
git remote add billing-import /tmp/billing-extract
git fetch billing-import
# 4. The histories share no ancestor, so Git must be told to merge them.
git merge --allow-unrelated-histories \
-m "Import libs/billing with history" billing-import/main
# 5. Drop the temporary remote; the commits are now part of this repo.
git remote remove billing-import
# Check that history follows the files into their new location.
git log --oneline -- libs/billing | head
# Built-in alternative (slower, single prefix), run in a clone of the source repo:
# git subtree split --prefix=packages/billing -b billing-only
# Nx alternative: read the options for your installed version first.
# npx nx import --help
Extracting a project directory with git filter-repo, renaming its old and current paths to the target layout, and merging it into an Nx workspace with --allow-unrelated-histories, with git subtree split and nx import noted as alternatives.
Recovering history for files that were renamed or moved
Path filtering keeps a commit's changes only when they touched the paths you selected. If the project was ever relocated, for example from libs/billing to libs/payments/billing with the @nx/workspace:move generator, every commit from before the relocation refers to the old path and is dropped. The imported history then starts at the move commit, which looks like the whole project was created in one change.
To find earlier locations, run git log --follow --name-status against a few representative files in the project, such as its project.json and a long-lived source file. --follow tracks renames for a single file only, not a directory, so check several files. In the output, R entries show each old path. Then pass one --path argument per location to git filter-repo, so commits under any of them are kept. Add --path-rename to map the old directories onto the final location. Without it, the old directories reappear as stray folders in early commits.
This recovers the project's files, but not the context around them. Directory-scoped history drops root nx.json, tsconfig.base.json, shared library and lockfile changes that were made in the same commits. As a result, most imported commits will not build on their own, and git bisect is only reliable on commits made after the import.
Git LFS needs separate handling. Filtering rewrites pointer files but does not copy the objects they reference. Run git lfs fetch --all in the source repository before filtering, then push those objects to the target's LFS store. If you skip this, the pointers resolve to nothing and nothing warns you. Large binaries that are not tracked by LFS come across in full, so check the size of the filtered repository before you push.
# Run in a fresh clone; filter-repo rewrites history in place.
git clone git@example.com:acme/old-monorepo.git billing-export
cd billing-export
# 1. Find earlier locations. --follow works on one file only, so check several.
# 'R' lines show old -> new paths.
git log --follow --name-status --format='%h %s' -- libs/payments/billing/project.json
git log --follow --name-status --format='%h %s' -- libs/payments/billing/src/index.ts
# 2. Download every LFS object before rewriting.
# Filtering rewrites pointer files but does not copy the objects behind them.
git lfs fetch --all
# 3. Keep history from every location, then map the old directory onto the final one.
# Without --path-rename, libs/billing/ shows up as a stray folder in early commits.
git filter-repo \
--path libs/billing/ \
--path libs/payments/billing/ \
--path-rename libs/billing/:libs/payments/billing/
# 4. Check the size before pushing. Binaries not tracked by LFS come across in full.
git count-objects -vH
# 5. Push LFS objects first so the pointers resolve, then push the history.
# filter-repo removes 'origin', so add the target remote explicitly.
git remote add target git@example.com:acme/new-monorepo.git
git lfs push --all target
git push target HEAD:refs/heads/import/billing
This script finds a project's earlier paths with git log --follow, keeps and renames all of them in one git filter-repo run, and fetches and pushes LFS objects explicitly so the pointer files still resolve after the import.
Rewiring the project in the target workspace
Once the history has landed, the project still describes itself in terms of the old workspace. Start with project.json. Check that name is unique in the target, since Nx refuses to build a project graph with two projects sharing a name. Point sourceRoot at the new directory. Remove implicitDependencies entries that name projects which don't exist here. Translate tags into the target's tagging scheme. Tags matter because the @nx/enforce-module-boundaries rule matches on them through depConstraints. A tag the target doesn't recognise either blocks legitimate imports or matches no constraint at all, and in the second case nothing gets checked. Add the project's import alias to the paths map in the target's root tsconfig, and update any relative extends paths in the project's own tsconfig files.
Next, compare each target in project.json against targetDefaults in the target's nx.json. Options set at the project level override the defaults, so delete options the defaults already provide and keep only the ones that are actually specific to this project. If you copy every option across, the project can drift from the rest of the workspace without anyone noticing.
Don't bring the source lockfile with you. Add the project's dependencies to the target's root package.json at the versions the target already uses. Then fix whatever breaks. That approach surfaces version conflicts now instead of letting them hide as duplicate installs.
Then confirm that CI picks the project up. If the pipeline uses nx affected, the project is included automatically. If it uses hard-coded project lists or path filters, you have to add it yourself. Also review namedInputs so the cache hashes the new paths.
Expect the first CI run after the merge to be heavy. The merge changes the lockfile, the root tsconfig and nx.json. Most projects depend on those files, so affected detection marks most of the workspace as changed.
#!/usr/bin/env bash
# Post-merge checks for a project moved into this workspace.
# PROJECT and the base branch are placeholders.
set -euo pipefail
PROJECT=billing-api
BASE=origin/main
# Clear cached graph and task results computed before the move.
npx nx reset
# Fails if the project graph cannot be built, e.g. because
# two projects share the same name.
npx nx show projects > /dev/null
# Print the resolved configuration: project.json merged with
# targetDefaults from nx.json. Check sourceRoot, tags and which
# options come from defaults and which are project overrides.
npx nx show project "$PROJECT" --json
# Install from the target's root package.json and lockfile.
# Do not copy in the source repo's lockfile.
npm install
# Lint runs @nx/enforce-module-boundaries against the new tags.
npx nx run-many -t lint test build -p "$PROJECT"
# Preview the first affected run. Expect a long list, because the
# history merge shows up as a large diff against the base.
npx nx show projects --affected --base="$BASE" --head=HEAD
A post-merge check script that rebuilds the project graph, prints the resolved project configuration, runs lint, test and build for the moved project, and previews how many projects the first affected run will include.
Verifying the move and cleaning up the source repository
Verification has to happen before anything is deleted from the source, because the source is still your only known-good copy.
Verify in the target
- Compare file trees. Diff the project directory in the source against its new path in the target, excluding build output and
node_modules. Any difference should be a rewrite you made on purpose, such as an import path or a config key. - Check history on real files. Run
git log --followandgit blameon a few files, including at least one that was renamed. If the oldest commit shown is the import commit, history did not carry through for that file. - Check the graph. Open
nx graphand confirm the moved project has the dependency edges you mapped earlier. A missing edge usually means a path alias or an implicit dependency was not carried over. - Run affected targets against the merge base. Run
nx affected -t build test lintwith--baseset togit merge-baseof your branch and main. This covers the moved project and every new dependent in one run.
Clean up the source
Remove the project in an ordinary commit. Do not rewrite the source's history: other clones, open branches and tags still refer to it. Point the remaining consumers at a published, versioned package. Then update CODEOWNERS, CI path filters and release configuration so none of them refers to the deleted path. Add a short README or commit message in the old location that names the new repository.
Pre-merge runbook
- The tree diff shows only intended changes.
git log --followreaches pre-move commits on a renamed file.nx graphmatches your dependency map.- Affected build, test and lint pass against the merge base.
- The source removal is in a separate pull request that is ready to merge.
Trade-offs and impact on cost and engineering time
Rewriting history has costs. Every imported commit gets a new hash, so links from issues, pull requests, changelogs and deploy records point at commits that do not exist in the target. Signed commits lose valid signatures because the signature covered the original commit object. Merging unrelated histories also interleaves the imported log with the target's own log, which makes git log noisier and gives git bisect more commits to step through.
The alternative is a single squashed import commit whose message references the archived source repository and its final commit hash.
| Concern | Full history import | Squashed import + archive reference |
|---|---|---|
| Blame inside target | Original authors and messages | One commit for all lines |
| Old hashes resolve | No, they are rewritten | Yes, in the archive |
| Signatures | Invalidated | Intact in archive |
| Log and bisect noise | Higher | Minimal |
| Import time on large repos | Filtering can be slow, adds heavy history | Fast, small |
Splitting also removes atomic cross-project changes. A shared library change that used to ship in one pull request now needs a versioned release and coordinated consumer upgrades.
You get time back in three places. CI only runs on the smaller graph. Each repository has a clear owner. With full history, blame keeps its context, so debugging a regression starts from the commit that introduced it instead of the import.
Illustrative estimate (assumed numbers, not a benchmark): saved CI minutes per month = PRs × (old minutes − new minutes). With 400 PRs, 30 minutes before the split and 18 minutes after, that is 400 × 12 = 4,800 minutes per month.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx move project between repos, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.