If you’ve ever tried to merge multiple Node.js or frontend apps into a single monorepo and struggled with conflicting package-lock.json files, this guide explains how to unify them under one root using NPM workspaces — with scripts to verify nothing breaks.
Unifying Multiple Apps Into One Monorepo With a Single Root Lockfile (npm Workspaces)
This article explains how to bring multiple independent applications into a single monorepo that uses one shared npm workspace and one root lockfile. It focuses on the practical approach: unify under one root workspace, let npm re-resolve the dependency graph, and validate the result with a simple diff of dependency trees. Hoisting, inter-app dependencies, publishing/versioning, deployment, engine/platform details, and governance are out of scope.
Who this is for
- Audience: Engineers running large monorepos with multiple separately-evolved applications who want deterministic installs and a single source of truth for dependencies.
- Prerequisites: Familiarity with npm workspaces and lockfiles. We’ll use npm; the concepts map to pnpm/Yarn with different lockfile shapes.
Problem statement
Multiple applications evolved in separate repositories with their own package.json
and package-lock.json
. When consolidating them into one monorepo, keeping per-app lockfiles introduces drift and non-determinism. We want:
-
One root workspace (root
package.json
withworkspaces
) -
One root lockfile (
package-lock.json
at repo root) -
Deterministic installs via
npm install
at the repository root only - A way to validate that each app’s dependency tree stays as intended across the migration
Approach (high level)
-
Unify under a single workspace: Move apps into the repo and declare them in the root
workspaces
array. - Merge lockfile state conceptually: Bring per-app lockfile information under a workspace-scoped namespace in the root lockfile (helper script optional), then reinstall at the root and let npm compute the final graph.
- Validate: Snapshot each app’s dependency tree before and after the merge and compare.
Target workspace layout
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}
Example file tree:
.
├─ package.json # root workspace definition (single source of truth)
├─ package-lock.json # unified root lockfile
├─ apps/
│ ├─ app-a/
│ │ ├─ package.json
│ │ └─ ...
│ └─ app-b/
│ ├─ package.json
│ └─ ...
└─ packages/ # optional shared libraries
└─ ui-components/
├─ package.json
└─ ...
Migration flow (end-to-end)
1) Move apps into the monorepo under apps/<name>
with their own package.json
.
2) Configure root workspace by listing apps in root package.json
-> workspaces
.
3) Snapshot each app’s dependency tree (before) to establish a baseline:
cd apps/app-a && npm install && npm ls --all > versions-before.txt
cd ../../
cd apps/app-b && npm install && npm ls --all > versions-before.txt
cd ../../
4) Converge to a single root lockfile:
- Preferred: run
npm install
at the repo root to let npm compute the unified graph. - Optional helper: a script that conceptually folds per-app lockfile entries into the root lockfile under
apps/<app-name>/...
prior to the reinstall. This can reduce noisy diffs and make the transition easier to reason about.
5) Reinstall at the root:
npm install
npm ls --all > versions-after.txt
6) Compare per-app trees (after vs. before) using a small diff script. Fail CI if unwanted changes are detected.
The rest of this article focuses on two small scripts you can adopt or adapt:
-
merge-to-root.ts
(optional helper): conceptually folds a workspace lockfile into the root lockfile before reinstalling. -
diff-workspace-deps.ts
: compares an app’s “before” vs “after” dependency trees.
Optional merge helper: merge-to-root.ts
Purpose: A pragmatic helper if you want to seed the root lockfile with each app’s lockfile content (namespaced under the workspace path) before running the root npm install
. This is not strictly required; you can skip this entire step and rely solely on npm install
at the root to produce the unified lockfile.
Conceptually, the script does three things for each workspace you fold in:
- Reads the root
package-lock.json
and the app’spackage-lock.json
. - Copies the app’s lockfile entries into the root lockfile under a namespaced key like
apps/app-a/node_modules/...
and creates a top-levelpackages["apps/app-a"]
entry with the app’s own metadata. - Writes the updated root lockfile; you then run
npm install
at the root to let npm reconcile.
Example (illustrative, partial — adapt as needed):
// scripts/merge-to-root.ts
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
type Json = Record<string, any>;
async function readJson(file: string): Promise<Json> {
return JSON.parse(await readFile(file, 'utf8')) as Json;
}
async function writeJson(file: string, data: Json): Promise<void> {
const text = `${JSON.stringify(data, null, 2)}\n`;
await writeFile(file, text, 'utf8');
}
function namespacePackages(packages: Record<string, any>, namespace: string) {
const out: Record<string, any> = {};
for (const [key, value] of Object.entries(packages)) {
if (key === '') continue; // handled separately for workspace root entry
if (key.startsWith('node_modules')) {
out[`${namespace}/${key}`] = value;
} else {
// leave other keys as-is (e.g., other workspaces already present)
out[key] = value;
}
}
return out;
}
function foldWorkspaceIntoRoot(
rootLockfile: Json,
workspaceLockfile: Json,
workspaceDir: string, // e.g., 'apps/app-a'
): Json {
const rootPackages = (rootLockfile.packages ?? {}) as Record<string, any>;
const wsPackages = (workspaceLockfile.packages ?? {}) as Record<string, any>;
// Create a namespaced view of the workspace's node_modules entries
const namespaced = namespacePackages(wsPackages, workspaceDir);
// Copy namespaced entries into the root map
const mergedPackages: Record<string, any> = { ...rootPackages, ...namespaced };
// Create the workspace's top-level entry (metadata from the workspace lockfile root "" entry)
const wsRoot = wsPackages[''] ?? {};
mergedPackages[workspaceDir] = {
version: wsRoot.version,
dependencies: wsRoot.dependencies,
devDependencies: wsRoot.devDependencies,
engines: wsRoot.engines,
};
return {
...rootLockfile,
packages: mergedPackages,
};
}
async function main() {
const workspaceName = process.argv[2]; // e.g., 'app-a'
if (!workspaceName) {
console.error('Usage: bun ts scripts/merge-to-root.ts <workspaceName>');
process.exit(1);
}
const workspaceDir = path.join('apps', workspaceName);
const rootLockPath = path.resolve('package-lock.json');
const wsLockPath = path.resolve(workspaceDir, 'package-lock.json');
const [rootLock, wsLock] = await Promise.all([
readJson(rootLockPath),
readJson(wsLockPath),
]);
const merged = foldWorkspaceIntoRoot(rootLock, wsLock, workspaceDir);
await writeJson(rootLockPath, merged);
console.log(`Seeded ${workspaceName} into root lockfile. Now run: npm install`);
}
await main();
Usage examples:
# Seed app-a into the root lockfile (optional helper), then reinstall
bun ts scripts/merge-to-root.ts app-a
npm install
Notes:
- This helper’s goal is to reduce churn and make the transition predictable. It is not a generic, lossless lockfile merger; npm’s resolver remains the source of truth once you run
npm install
at the root. - If you prefer not to seed, skip this step and rely solely on
npm install
at the root.
Dependency diff helper: diff-workspace-deps.ts
Purpose: Compare an individual workspace’s dependency tree before vs. after unification. It reads npm ls --all
output and reports:
- CHANGED (packages whose version set changed)
- ADDED (new
name@version
combos) - REMOVED (removed
name@version
combos)
Design:
- “Before” file is captured inside the app (when it was standalone or before unification):
apps/app-a/versions-before.txt
. - “After” file is captured at the repo root after
npm install
:versions-after.txt
. - The parser extracts the dependency subtree for the specific workspace from the monorepo-level
npm ls
tree by detecting theapp-a@<version> -> ./apps/app-a
block and collecting lines until indentation returns to the root.
Example (illustrative, partial):
// scripts/diff-workspace-deps.ts
/* eslint-disable no-console */
import { readFile } from 'node:fs/promises';
import path from 'node:path';
type VersionSetMap = Map<string, Set<string>>;
function extract(line: string) {
const m = line.match(/((?:@[^\/\s]+\/)?[A-Za-z0-9._-]+)@([0-9][^\s]*)/);
return m ? { name: m[1], version: m[2] } : null;
}
function parseBefore(content: string, workspaceName: string): VersionSetMap {
const lines = content.split('\n');
const out: VersionSetMap = new Map();
const rootRe = new RegExp(`^${workspaceName}@[0-9]`);
for (const line of lines) {
if (rootRe.test(line)) continue; // skip the root line like "app-a@1.2.3 /path"
const nv = extract(line);
if (nv && nv.name !== workspaceName) (out.get(nv.name) ?? out.set(nv.name, new Set()).get(nv.name)!).add(nv.version);
}
return out;
}
function parseAfter(content: string, workspaceName: string): VersionSetMap {
const lines = content.split('\n');
// Example monorepo tree line: "└─┬ app-a@1.2.3 -> ./apps/app-a"
const rootRe = new RegExp(`^[├└]─[┬─]\s+${workspaceName}@`);
let inBlock = false;
const out: VersionSetMap = new Map();
for (const line of lines) {
if (!inBlock) {
if (rootRe.test(line)) inBlock = true;
continue;
}
// Stop when indentation returns to the repo root
if (!line.startsWith('│') && !line.startsWith(' ')) break;
const nv = extract(line);
if (nv && nv.name !== workspaceName) (out.get(nv.name) ?? out.set(nv.name, new Set()).get(nv.name)!).add(nv.version);
}
return out;
}
function compare(before: VersionSetMap, after: VersionSetMap) {
const names = new Set([...before.keys(), ...after.keys()]);
const combos = (m: VersionSetMap) => {
const s = new Set<string>();
for (const [n, vs] of m) for (const v of vs) s.add(`${n}@${v}`);
return s;
};
const bCombos = combos(before);
const aCombos = combos(after);
const added = [...aCombos].filter((c) => !bCombos.has(c)).sort();
const removed = [...bCombos].filter((c) => !aCombos.has(c)).sort();
const changed: string[] = [];
for (const n of names) {
const b = [...(before.get(n) ?? new Set())].sort();
const a = [...(after.get(n) ?? new Set())].sort();
const eq = b.length === a.length && b.every((v, i) => v === a[i]);
if (!eq && (b.length || a.length)) changed.push(`${n}: [${b.join(', ')}] -> [${a.join(', ')}]`);
}
return { changed, added, removed };
}
async function main() {
const workspaceName = process.argv[2];
const beforeArg = process.argv[3] ?? path.join('apps', workspaceName ?? '', 'versions-before.txt');
const afterArg = process.argv[4] ?? path.join('versions-after.txt');
if (!workspaceName) {
console.error('Usage: bun ts scripts/diff-workspace-deps.ts <workspaceName> [beforeFile] [afterFile]');
process.exit(1);
}
const [beforeContent, afterContent] = await Promise.all([
readFile(path.resolve(beforeArg), 'utf8'),
readFile(path.resolve(afterArg), 'utf8'),
]);
const beforeMap = parseBefore(beforeContent, workspaceName);
const afterMap = parseAfter(afterContent, workspaceName);
const { changed, added, removed } = compare(beforeMap, afterMap);
console.log('CHANGED (by package)');
if (changed.length) console.log(changed.join('\n'));
console.log('\nADDED (name@version)');
if (added.length) console.log(added.join('\n'));
console.log('\nREMOVED (name@version)');
if (removed.length) console.log(removed.join('\n'));
if (changed.length) {
console.error('\nDependency changes detected');
process.exit(1);
}
}
await main();
Usage examples:
# Generate snapshots
cd apps/app-a && npm ls --all > versions-before.txt && cd -
npm install && npm ls --all > versions-after.txt
# Diff app-a only
bun ts scripts/diff-workspace-deps.ts app-a apps/app-a/versions-before.txt versions-after.txt
What to do with results:
- Empty “CHANGED/ADDED/REMOVED” means your app’s dependency tree stayed equivalent.
- Non-empty output means your unified install altered the app’s resolved dependencies. Decide whether to accept or adjust before proceeding.
End-to-end example (bringing two apps into one root lockfile)
# 1) Add apps to the monorepo under apps/
mkdir -p apps/app-a apps/app-b
# (copy in each app's package.json and source)
# 2) Configure the root workspace (package.json workspaces)
# Then optionally install in each app and snapshot "before"
cd apps/app-a && npm install && npm ls --all > versions-before.txt && cd -
cd apps/app-b && npm install && npm ls --all > versions-before.txt && cd -
# 3) (Optional) Seed app-a into the root lockfile to reduce churn
bun ts scripts/merge-to-root.ts app-a
# 4) Install once at the root to produce a unified lockfile
npm install
npm ls --all > versions-after.txt
# 5) Validate each app
bun ts scripts/diff-workspace-deps.ts app-a apps/app-a/versions-before.txt versions-after.txt
bun ts scripts/diff-workspace-deps.ts app-b apps/app-b/versions-before.txt versions-after.txt
# Use exit codes to gate CI: non-zero indicates changes
Notes on npm vs pnpm/Yarn
- The approach (single workspace, reinstall to reconcile, validate via diffs) generalizes.
- The lockfile structure differs. If you adapt the optional merge helper, update parsing/keys accordingly.
- If you skip the merge helper and rely on
npm install
at the root, the process is nearly identical across tools.
Pitfalls and guardrails (brief)
- Lockfile version mismatch: Align lockfile versions before you start.
- Unexpected dependency changes: Rely on the diff script to detect. Review and accept or fix intentionally.
Conclusion
Unifying multiple apps under a single root workspace and lockfile simplifies dependency management and installations across a large monorepo. A small amount of automation — optional seed-merge and a deterministic diff — makes the migration observable and CI-friendly without relying on bespoke, fragile lockfile reconciliation.
Top comments (0)