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

Converting Shared Code into Internal Workspace Packages

After repositories are merged into a monorepo, shared code usually exists in the worst possible forms: copied folders that have drifted apart, deep relative imports from one application into another, and tsconfig path aliases pointing across the tree. None of it appears in the dependency graph, so task runners order and cache builds wrongly, and a fix in one copy never reaches the others. Converting that code into internal workspace packages — small, private, named packages with a declared public entry point — puts every relationship into the graph. This guide covers finding the shared code, designing the package boundary, choosing between built and source-only packages, and migrating imports without a big-bang change.

Finding the candidates

Shared code hides in three patterns:

# 1. Relative imports that leave the importing package
grep -rnE "from '(\.\./){2,}(apps|packages)/" apps packages | head

# 2. Path aliases pointing into other folders
grep -rn '"paths"' apps/*/tsconfig.json packages/*/tsconfig.json

# 3. Duplicated modules: files with the same name in several packages
find apps packages -name "*.ts" -path "*/src/*" -not -path "*/node_modules/*" \
  | xargs -n1 basename | sort | uniq -c | sort -rn | awk '$1 > 1' | head

The third search is crude but effective: formatCurrency.ts or apiClient.ts appearing in four applications is a strong candidate. Compare the copies with a diff tool before consolidating; they often diverged for real reasons that the new package must support. The migration context is covered in Monorepo Migration and Adoption.

Shared code before and after packaging Left panel shows two apps importing a helper through relative paths and a copy; right panel shows both apps depending on an internal package through the workspace protocol. Before: copies and deep imports apps/web/src/lib/format.ts apps/admin/src/utils/format.ts (copy, drifted) apps/api/src/x.ts: import { fmt } from '../../../web/src/lib/format' After: one internal package packages/format/ (@acme/format) src/index.ts (public API) apps/*/package.json: "@acme/format": "workspace:*" import { fmt } from '@acme/format'
A package turns hidden file-level coupling into a declared dependency every tool can see.

Designing the package boundary

A useful internal package has a clear purpose, a small public API and no dependency on the applications that use it. Before moving files, decide:

  • Name and scope. @acme/format, @acme/api-client, @acme/ui. Name for what it does. Use the same scope as published packages so a later publish needs no rename.
  • Public API. What goes in src/index.ts. Everything else is internal and should not be importable, which the exports map enforces.
  • Dependencies. The package declares its own. If the shared code imports React or a date library, those become dependencies (or peers for frameworks) of the package.
  • Direction. Packages must not import from apps/. If shared code reaches into an application for configuration or types, invert the dependency by passing them in as parameters.

Keep packages small and cohesive. One @acme/shared grab-bag package recreates the coupling you are trying to remove: every change to it invalidates every consumer's cache, and nobody owns it.

Built packages versus source-only packages

Internal packages that are never published do not have to be built. Two models work well:

Built internal packages versus source-only internal packages Compares internal packages that compile to dist with packages that export TypeScript source for consumers' bundlers to compile. built (dist/) source-only (src/) Build step required, ordered none Works with bundled apps yes yes Works in plain Node.js yes only with type stripping Cacheable build task yes nothing to cache Ready to publish later yes needs a build first
Source-only packages are simpler for apps built by bundlers; built packages are needed when Node.js loads them directly or they may be published.

A source-only package points its exports at TypeScript files and relies on each consumer's bundler or TypeScript setup to compile them:

{
  "name": "@acme/format",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "dependencies": {
    "date-fns": "catalog:"
  }
}

A built package compiles to dist/ and exports that, with a development condition if you want source during development:

{
  "name": "@acme/api-client",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "development": "./src/index.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": { "build": "tsup src/index.ts --format esm --dts" }
}

Choose source-only for packages consumed only by bundled applications; choose built packages for anything a Node.js service loads directly, anything that might be published, and anything expensive to compile that benefits from caching. The trade-offs are covered in detail in Using Internal Packages Without a Build Step.

Migrating imports incrementally

Move code in small steps so each pull request is reviewable and reversible:

  1. Create the package with the chosen shared implementation and its tests.
  2. Add it as a dependency of one consumer with workspace:* and switch that consumer's imports to the package name.
  3. Delete that consumer's copy once its tests pass.
  4. Repeat per consumer. Where a copy diverged, either extend the package's API to cover both behaviours or keep the difference in the consumer as a thin wrapper.
  5. Add a lint rule that forbids the old paths, such as no-restricted-imports for **/lib/format or import/no-relative-packages, so the copies cannot come back.
Consolidating one shared module Create the package, switch one consumer, delete its copy, repeat for remaining consumers, then add a lint rule that blocks the old import paths. create @acme/format code + tests + exports map pick the most complete copy switch apps/web workspace:* and package imports delete web's copy tests still pass repeat for admin, api reconcile divergent copies lint rule block old paths for good
One consumer per pull request keeps every step small and reversible.

Reconciling copies that diverged

The hardest part of consolidation is rarely the move; it is deciding what the shared code should do when the copies disagree. Treat each divergence as a question for the owners of the consumers, not as noise to be flattened.

Start by diffing every copy against the one you chose as the base, and classify each difference. Bug fixes applied to one copy only should be kept — the consolidated package gets the fix, and the other consumers get it for free. Behavioural differences that consumers rely on — a different date format, a stricter validation rule — become options in the package's API, with the most common behaviour as the default. Accidental differences — renamed variables, different code style — are simply dropped. Write a test for every behaviour you keep, using the consumers' existing tests as a source, so the package's contract is explicit before the first consumer switches.

A useful technique when the differences are unclear is to temporarily run both implementations side by side in one consumer: call the new package and the old copy, compare the results in development or in a sampled production path, and log mismatches. After a few days without mismatches, delete the old copy with confidence.

Keeping the package healthy

An internal package is a product with internal customers. Give it an owner in CODEOWNERS, a short README that describes its public API and the built-versus-source decision, and the same lint, type-check and test tasks as every other package. Resist adding consumer-specific code paths; if one consumer needs special behaviour, it can wrap the package locally. And watch its dependency footprint: every dependency added to a shared package becomes a dependency of every consumer, so heavy libraries belong in the consumers that need them, not in the shared layer.

Wiring it into the graph

Once packages exist, the tools can finally see the relationships:

  • The task runner builds @acme/api-client before the applications that depend on it and invalidates their caches when it changes.
  • Affected detection includes every consumer when a shared package changes, as covered in Fixing Slow Monorepo CI with Affected Builds.
  • CODEOWNERS can assign an owner to packages/format/.
  • Boundary rules can restrict which packages may depend on it.

Check that edges are declared rather than inferred: every consumer lists the package in dependencies or devDependencies, as explained in Detecting Undeclared Cross-Package Imports.

Worked example: four copies of an API client

After merging four repositories, a team finds four copies of a hand-written API client, each with slightly different retry logic and error types. They create @acme/api-client as a built package from the most complete copy, add configurable retries to cover the differences, and migrate one application per pull request over two weeks. Each migration deletes around 600 lines. When a later security fix to the client's token refresh logic lands, it reaches all four applications in one pull request, tested together — the change that would previously have needed four separate fixes and deployments.

Prevention and guardrails

  • Never import across package folders with relative paths; enforce it with lint.
  • Keep internal packages small and cohesive, with one owner each.
  • Declare every internal dependency with the workspace protocol.
  • Decide built versus source-only per package, and document the choice in its README.

Frequently Asked Questions

Should internal packages have version numbers? Private packages can stay at 0.0.0 and use workspace:*. Version numbers matter only if a package is published.

How small is too small for a package? A package per function is too fine-grained; the overhead of manifests and configuration outweighs the benefit. Group code that changes together and is owned by the same people.

What about shared types only? A types-only internal package is fine and common. Export types from src/index.ts, mark imports with import type, and it adds no runtime code to consumers.

Can an internal package later be published? Yes, if it is a built package with a correct exports map and declared dependencies. Remove "private": true, give it a real version, and run the validation checks in Testing and Validating Packages Before Publishing before the first release.

What if two applications need incompatible versions of the shared code? That is usually a sign the code is really two things. Split it into two packages, or keep the divergent part in each application and share only the common core.

Related

Monorepo Migration and Adoption