Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Monorepo Migration and Adoption

Moving from many repositories to one — or deciding which parts should stay separate — is an organisational change as much as a technical one, and most failed monorepo adoptions fail on the transition rather than the destination. Teams lose git history, break every consumer's imports at once, freeze feature work for weeks, or end up with a monorepo that has all the coordination costs and none of the benefits. This section covers how to plan a migration, bring repositories together without losing history, turn copied code into proper internal packages, set up ownership, and, when needed, split a package back out.

When a monorepo is worth it

A monorepo pays off when packages change together. If a typical feature touches a shared library and two applications, doing that in three repositories means three pull requests, a publish in between, and version bumps before the applications can consume the change. In one repository it is one atomic pull request, tested together. The broader architecture is covered in Monorepo Architecture & Orchestration.

The signals that a migration will help:

  • Cross-repository changes are routine, and coordinating them causes delays or broken intermediate states.
  • The same code is copied between repositories because publishing a shared package felt too heavy.
  • Tooling (lint rules, TypeScript settings, CI templates) drifts between repositories and is updated repository by repository.
  • Dependency versions differ across applications that should behave the same.

The signals that it will not: repositories owned by teams that rarely interact, with different release cadences, security boundaries or languages, and no shared code. Consolidating those adds coordination without removing any.

Polyrepo versus monorepo trade-offs Compares separate repositories and a single monorepo on cross-package changes, shared tooling, dependency alignment, access control and CI complexity. polyrepo monorepo Change across packages several PRs + publishes one atomic PR Shared tooling and config copied, drifts one source Dependency alignment per repo enforceable Access control per repository CODEOWNERS, path rules CI at scale small pipelines needs affected runs + caching
A monorepo trades access-control simplicity and CI simplicity for atomic changes and shared tooling.

Concept overview: the migration phases

A successful migration moves in phases, each of which leaves every repository in a working state. Skipping phases — especially the "prove the tooling" phase — is how migrations stall halfway.

Phases of a monorepo migration Five phases from choosing tooling and a pilot, through merging repositories with history, converting shared code to packages, and setting up ownership, to decommissioning old repositories. 1. Tooling pilot workspace + task runner on 2 repos 2. Merge with history subtree or filter-repo 3. Shared packages copied code to workspace packages 4. Ownership + CI CODEOWNERS, affected runs 5. Decommission archive old repositories
Each phase ends with everything working; old repositories stay read-only until the last phase.
  1. Tooling pilot. Choose the package manager, workspace layout and task runner with two closely related repositories. Prove that installs, builds, tests and releases work before bringing in everything else.
  2. Merge with history. Import each repository into a subdirectory while keeping its commits, as described in Merging Repositories While Preserving Git History.
  3. Consolidate shared code. Replace copies and cross-repository dependencies with internal workspace packages — see Converting Shared Code into Internal Workspace Packages.
  4. Ownership and CI. Route reviews with CODEOWNERS, as in Setting Up CODEOWNERS for Monorepo Packages, and make CI affected-only with caching before the repository grows further.
  5. Decommission. Archive the old repositories, redirect their README files, and close their pipelines.

The reverse operation — extracting a package into its own repository — is covered in Splitting a Package Out of a Monorepo.

Planning: inventory before you move anything

A migration plan starts with an inventory of what exists. For each candidate repository, record five things: its package manager and version, its Node.js version, how it builds and tests (scripts, CI configuration), how it releases (published packages, deploy targets), and which other repositories it depends on or is depended on by. A spreadsheet is enough; the value is in seeing the whole picture before choosing an order.

The inventory drives three decisions. Import order: bring in the repositories with the most cross-dependencies first, because that is where atomic changes pay off soonest, and leave isolated repositories until last — or out altogether. Toolchain convergence: note every repository that uses a different package manager, test runner or TypeScript version, because each difference becomes work at import time. It is usually easier to converge a repository's toolchain while it is still separate (for example, switching it from Yarn Classic to pnpm in its own repository) and import it afterwards, so the import itself is a pure move. Release continuity: list every published package and deployed service, and decide how each will be released from the monorepo before its repository is imported, so no release is blocked by the move.

Import order driven by dependencies between repositories The shared UI and API client repositories are imported first because both applications depend on them; the applications follow, and an isolated service is imported last or left separate. acme-ui import first acme-api-client import first acme-web then apps acme-admin then apps acme-billing isolated: last or never
Import the most depended-on repositories first so each later import can switch to workspace dependencies immediately.

Converging toolchains as projects arrive

Imported projects rarely match the monorepo's conventions on arrival. Resist the urge to rewrite everything during the import; land the project first, with its own scripts working inside the workspace, then converge in small follow-up pull requests. A practical order is: package manager and lockfile (mandatory at import, because there is only one lockfile), TypeScript configuration (extend the shared base, fix errors that stricter settings reveal), lint configuration, test runner, and finally build tooling. Each step is independently reviewable, and a project that is still on its old test runner can live happily in the monorepo for weeks while the rest converges.

Scripts are the glue that makes this possible. As long as every imported project exposes the standard script names — build, test, lint, typecheck — the task runner can orchestrate it regardless of which tools those scripts call internally.

Core initialisation and configuration

Set up the target repository before importing anything, so each imported project lands in a working structure:

mkdir acme && cd acme && git init -b main
corepack enable && corepack use pnpm@9.15.4
mkdir -p apps packages tooling
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
  - "tooling/*"
{
  "name": "acme",
  "private": true,
  "packageManager": "pnpm@9.15.4",
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck"
  },
  "devDependencies": {
    "turbo": "2.3.3"
  }
}

Add the shared tooling packages first — tooling/tsconfig, tooling/eslint-config — so imported projects can adopt them one by one. Shared configuration is covered in Sharing a Base tsconfig Across Workspaces.

Architecture: layout and naming decisions

Decisions made at migration time are expensive to change later, so settle them explicitly.

Folder layout. apps/ for deployable applications and packages/ for libraries is the most common convention; some repositories add tooling/ or services/. The layout matters less than consistency and matching the task runner's globs.

Package names. Use one npm scope for everything (@acme/*), even private packages, so imports read the same whether a package is published or internal and so a future publish needs no rename. Name packages after what they are, not after the repository they came from.

Versioning strategy. Decide whether published packages version together (fixed) or independently, before the first release from the monorepo — see Choosing Fixed vs Independent Versioning in a Monorepo.

Dependency policy. Decide how external versions are aligned — pnpm catalogs, syncpack or constraints — and apply it as projects are imported, rather than inheriting every repository's drift. Catalogs are covered in Sharing Dependency Versions with pnpm Catalogs.

A target layout for migrated repositories The monorepo root with pnpm workspace files, apps for deployables imported from old repositories, packages for shared libraries, and tooling for configuration packages. acme/ pnpm-workspace.yaml apps/*, packages/*, tooling/* turbo.json apps/ web/ was acme-web repository api/ was acme-api repository packages/ ui/ was copied into web and admin api-client/ was published separately tooling/ tsconfig/ eslint-config/ shared configuration packages
Imported repositories become apps or packages; shared configuration lives in tooling from day one.

Execution strategy: keeping work moving during migration

The biggest risk in a migration is blocking feature work. Three practices prevent it:

Import one repository at a time, and keep the original repository writable until its import is verified. Changes merged to the old repository after the import are brought over with a second, incremental import (both git subtree pull and a re-run of the filter-repo import support this).

Freeze each repository briefly, not all at once. A freeze of a day or two per repository — announced ahead of time — is enough to do the final import, switch CI and redirect contributors. Freezing everything for a "big bang" weekend concentrates risk.

Keep published package names stable. If @acme/api-client was published from its own repository, publish it from the monorepo under the same name and continue its version history. Consumers should not notice the move.

Releases and deployments during the transition

The period between the first import and the last decommission is when releases are most likely to go wrong, because two sources of truth briefly coexist. Three rules keep it safe. First, once a repository is imported, all new releases of its packages come from the monorepo; mark the old repository read-only and disable its release workflow in the same change. Second, continue version history: the first release from the monorepo should be the next version after the last one published from the old repository, which means importing its git tags or recording the current versions in the release tool's state. Third, verify the first monorepo release of each package with a dry run and a tarball comparison against the previous release, as described in Dry-Running a Publish Before Release, so any change in package contents is deliberate.

Deployments follow the same pattern. Point each service's deploy pipeline at the monorepo in the same pull request that imports it, and keep the old pipeline disabled but available for a short rollback window.

Measuring success

Decide up front what the migration is supposed to improve, and measure it before and after. Useful measures are the number of pull requests needed for a typical cross-cutting change, the time from merging a shared-library change to it being live in every application, median CI time for pull requests, the number of duplicated or copied modules, and the spread of versions for key dependencies across applications. If CI time goes up after the migration, that is a signal to invest in affected runs and caching, not a reason to abandon the monorepo; if cross-cutting changes still need several pull requests, code that should be shared has not been converted into packages yet.

Security and isolation

A monorepo concentrates access: anyone with write access to the repository can open pull requests against every package. Protect the boundaries that separate repositories used to provide:

  • CODEOWNERS with required reviews so changes to each package need approval from its owners.
  • Branch protection requiring status checks and reviews on main.
  • Path-scoped CI secrets. Deploy credentials for one application should only be available to that application's deploy job, on protected branches.
  • Module boundary rules that stop code in one domain importing another team's internals — see Enforcing Module Boundaries with Nx Tags or Detecting Undeclared Cross-Package Imports.

If some code genuinely requires restricted access — security-sensitive services, licensed third-party code — keep it in a separate repository and consume it as a published package. A monorepo does not have to contain everything.

CI/CD integration

The migrated repository needs affected-only CI and caching from the start, because pipelines that were fast in small repositories become slow once combined:

name: ci
on: [pull_request]
jobs:
  ci:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}     # remote cache
      TURBO_TEAM: acme
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }                  # history for affected detection
      - uses: pnpm/action-setup@v4                # version from packageManager
      - uses: actions/setup-node@v4
        with: { node-version-file: .nvmrc, cache: pnpm }
      - run: pnpm install --frozen-lockfile       # one lockfile for every imported project
      - run: pnpm turbo run lint typecheck test build --filter="...[origin/${{ github.base_ref }}]"

Annotated: the checkout fetches full history so the filter can find the merge base; the package manager version comes from the repository; the install uses the single root lockfile; the task runner runs only packages affected by the pull request, replaying cached results for anything unchanged. Deploy jobs for each application are separate workflows triggered by changes to that application or its dependencies. More techniques are covered in CI/CD Pipeline Optimization for Monorepos.

Communicating the change

Developers experience a migration as a set of changed habits: a new clone URL, a new install command, different CI checks, new review routing. Most friction comes from surprises rather than from the changes themselves, so communicate each phase before it happens.

Publish a short migration page in the monorepo's README that lists which repositories have moved, which are next, and the date of each freeze. For every imported repository, replace the old repository's README with a pointer to its new location and archive it rather than deleting it, so old links and bookmarks still lead somewhere useful. Provide a one-page "working in the monorepo" guide covering setup (corepack enable, pnpm install), the root scripts, how to run one application, and how CI decides what to test. Finally, name an owner for the monorepo's shared tooling — the root configuration, task runner, CI templates and shared config packages — so questions and upgrades have a home instead of being everyone's and no one's responsibility.

Expect a short dip in velocity after each import as people adjust, and plan the migration schedule around releases and holidays so the dip never coincides with a critical delivery.

Common anti-patterns after migration

A few patterns show up in monorepos that were migrated without follow-through. Copied packages living side by side: two versions of the same helper module imported from different repositories and never consolidated. Applications importing each other: apps/admin reaching into apps/web/src because it is now possible, creating coupling the old repository boundary prevented. A root package.json full of dependencies that belong to individual packages, carried over from whichever repository was imported first. CI that still runs everything because affected detection was deferred. Each of these erodes the benefits the migration was meant to deliver, and each has a fix covered elsewhere in this section — consolidate shared code into packages, enforce boundaries, declare dependencies where they are used, and make CI affected-only.

Pitfalls

Mistake Impact Remediation
Copying files instead of importing history git blame and history lost for every file Import with subtree or filter-repo
Big-bang migration of all repositories Weeks of frozen work, high risk Pilot, then one repository at a time
Keeping per-project lockfiles Inconsistent installs, drift One root lockfile, reinstall after each import
No CODEOWNERS or boundaries Teams change each other's code unreviewed CODEOWNERS + boundary lint rules
Full CI on every pull request Pipelines slow down as repositories combine Affected runs + remote caching from day one
Renaming published packages during the move Consumers break or miss updates Keep names and continue version history

Guides in this topic

Every guide below solves one concrete task or error within Monorepo Migration and Adoption. Start with the one whose symptom matches what you are seeing:

Frequently Asked Questions

How long does a migration take? The tooling pilot usually takes one to two weeks. Each additional repository then takes a day or two of focused work plus a short freeze. Consolidating copied code into shared packages continues gradually afterwards.

Do we have to move everything into the monorepo? No. Move repositories that change together. Independent services, restricted code and projects in other languages can stay separate and consume shared packages from a registry.

What happens to open pull requests in the old repositories? Merge or close them before the final import for each repository. Pull requests cannot be moved; long-running branches can be imported as branches with the same history-preserving tools, then re-opened in the monorepo.

Should we pick Nx or Turborepo before migrating? Pick during the pilot, with real projects. Both adopt existing workspaces easily, as described in Choosing a Monorepo Task Runner.

Can we migrate incrementally with some teams still in their own repositories? Yes, and most successful migrations do. Packages in the monorepo are published as before for consumers outside it, and repositories that have not moved keep consuming them from the registry. The only rule is that each package has exactly one home at a time.

What if the migration stalls halfway? A half-migrated state is stable as long as every package has one home and releases work. Resume by importing the next repository with the most cross-dependencies; avoid starting a new tooling change until the pending imports are done.

Related

Monorepo Architecture & Orchestration