Monorepos: Sharing Code Across Multiple Apps and Packages (Advanced, Optional)
Understand what a monorepo is, when it earns its complexity, and how workspaces, shared packages, shared types, package boundaries, and build orchestration work in an npm/pnpm-based JavaScript monorepo.
This lesson is advanced and optional — it's aimed at developers who will work on, or want to understand, larger organizations' codebases where a React app doesn't live alone but alongside a design system, a backend, shared utility packages, and possibly other frontend apps, all in one repository. Most solo projects and small teams never need this. But once you've seen a monorepo, you'll recognize the pattern everywhere in industry, and understanding it will make you far more effective the moment you join a team that uses one.
What a monorepo actually is
A monorepo is a single version-controlled repository that contains multiple, independently meaningful packages — for example a web app, a mobile app, a ui component library, and a shared-types package — instead of splitting each into its own separate git repository ('polyrepo'). The defining feature isn't just 'many folders in one repo'; it's that these packages can depend on each other directly, by name, and changes to a shared package are visible to its consumers instantly, without publishing to a registry.
The alternative, a polyrepo setup, requires publishing a shared package to npm (or a private registry) every time it changes, then bumping the version in every consuming repo — workable, but slow for fast-moving internal code shared across many apps. Monorepos trade that publish/update cycle for tooling complexity: you need a way to install dependencies across packages, run builds in the right order, and keep packages from silently depending on things they shouldn't.
Workspaces: how one install serves many packages
A 'workspace' is the mechanism package managers (npm, pnpm, Yarn) provide for monorepos: a root package.json declares which subfolders are packages, a single npm install (or pnpm install) at the root installs dependencies for every package at once, hoisting shared dependencies to avoid duplication, and packages within the workspace can depend on each other by name and get a symlink to the real source instead of a separately installed copy.
npm workspaces are declared with a workspaces array of glob patterns in the root package.json. pnpm uses a separate pnpm-workspace.yaml file instead, and is popular for monorepos specifically because of its stricter, more disk-efficient dependency resolution (a content-addressable store, with symlinked node_modules that prevent packages from accidentally importing dependencies they never declared — a problem npm and Yarn's flatter hoisting can silently allow).
Shared packages and shared types
A shared package is any workspace member meant to be consumed by others rather than run on its own — a ui package of shared React components, a utils package of pure functions, or a config package of shared ESLint/TypeScript configs. Consuming apps add it as a normal dependency ("@myorg/ui": "workspace:*" in pnpm, or a matching version range in npm), and the package manager links it locally instead of downloading it.
Shared types deserve special mention in a full-stack or multi-app monorepo: a shared-types (or contracts) package can define TypeScript interfaces for API request/response shapes, and both the backend that implements the API and every frontend app that calls it import the exact same types. This eliminates an entire class of bugs where the frontend's hand-written guess at a response shape silently drifts from what the backend actually returns — the compiler catches the mismatch immediately, on both sides, the moment either side changes.
Package boundaries
A package boundary is the discipline of only importing from a sibling package's declared public entry point (its package.json main/exports field), never by reaching into its internal file structure (e.g. import { helper } from "../../ui/src/internal/helper"). Boundaries exist because internal files are implementation detail that can change at any time; only the declared exports are a contract other packages should rely on.
Enforcing boundaries matters more as a monorepo grows: without it, packages become tightly and invisibly coupled through internals, and a 'small' refactor inside one package silently breaks five others that were never supposed to know those internals existed. Tools like ESLint's import/no-restricted-paths rule, or dedicated monorepo tools (Nx, Turborepo) with explicit dependency graphs, can enforce these boundaries automatically in CI.
Build orchestration
Build orchestration is the problem of running build/test/lint scripts across many interdependent packages in the correct order and, ideally, only for what actually changed. If web depends on ui, which depends on utils, a build must build utils first, then ui, then web — running them in parallel or in the wrong order produces stale or broken output.
npm/pnpm workspaces alone only handle installing dependencies and running a script in every package (npm run build --workspaces / pnpm -r run build); they don't understand the dependency graph between packages or cache previous results. Dedicated tools like Turborepo and Nx sit on top of workspaces specifically to solve this: they read each package's declared dependencies, build a task graph, run independent packages in parallel, run dependent ones in the correct order, and cache task outputs so an unrelated change elsewhere in the repo doesn't force a full rebuild.
• npm workspaces — dependency installation & linking, defined via root package.json's "workspaces" field. • pnpm workspaces — same concept, via pnpm-workspace.yaml, with stricter, more space-efficient linking. • Turborepo / Nx — build orchestration on top of workspaces: dependency-graph-aware task running, parallelization, and caching.
A minimal npm workspaces monorepo layout
The glob patterns "apps/*" and "packages/*" tell npm every immediate subfolder there is a workspace member. Internal cross-package versions like "*" (npm) or "workspace:*" (pnpm) tell the package manager to link the local source directly rather than fetch from a registry.
// package.json (repo root)
{
"name": "sampleapp-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"dev": "npm run dev --workspace=apps/web"
}
}
// apps/web/package.json
{
"name": "@example/web",
"dependencies": {
"@example/ui": "*",
"@example/shared-types": "*"
}
}
// packages/ui/package.json
{
"name": "@example/ui",
"main": "./src/index.ts",
"dependencies": {
"react": "^18.0.0"
}
}Code Examples
pnpm's workspace: protocol is explicit and unambiguous: it can only mean 'link the local workspace package,' never accidentally resolve to a same-named package published on the public npm registry, which is a real risk with plain semver ranges in npm/Yarn workspaces.
"@example/ui": "workspace:*" Both the backend route and the React hook import the exact same User type from one shared package. If a backend developer adds a required field to User, every frontend consumer immediately shows a type error at the exact spots that now need updating — instead of failing silently at runtime.
import type { User } from "@example/shared-types";
# pnpm-workspace.yaml (repo root)
packages:
- "apps/*"
- "packages/*"
# apps/web/package.json (dependency on a sibling package)
{
"dependencies": {
"@example/ui": "workspace:*",
"@example/shared-types": "workspace:*"
}
}// packages/shared-types/src/index.ts
export interface User {
id: string;
email: string;
displayName: string;
}
// apps/api/src/routes/users.ts (backend)
import type { User } from "@example/shared-types";
app.get("/users/:id", (req, res): void => {
const user: User = getUserFromDb(req.params.id);
res.json(user);
});
// apps/web/src/hooks/useUser.ts (React frontend)
import type { User } from "@example/shared-types";
export function useUser(id: string): User | null {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${id}`).then((r) => r.json()).then(setUser);
}, [id]);
return user;
}A typical monorepo dependency graph
Build orchestration tools read this graph from each package's declared dependencies and schedule tasks accordingly, instead of relying on a hand-maintained ordering.
apps/web ──depends on──▶ packages/ui ──depends on──▶ packages/utils
│ ▲
└───────────────────depends on────────────────────────────┘
└──depends on──▶ packages/shared-types ◀──depends on── apps/api
Build order (topological): utils → ui → shared-types → web, api (parallel where independent)Important Rules
Only import from a sibling package's declared public entry point, never from its internal file paths.
Declare cross-package dependencies explicitly in package.json, even inside a monorepo — never rely on accidental hoisting to make an undeclared import 'just work'.
Prefer pnpm's workspace: protocol (or npm's equivalent) over loose semver ranges for internal packages, to avoid ambiguity with the public registry.
Introduce a monorepo only when you actually have multiple packages that need to share code frequently — the tooling overhead isn't worth it for a single app.
Why larger engineering orgs reach for monorepos
Companies with a design system shared across several product surfaces, or a full-stack team that wants the frontend and backend to agree on API types automatically, commonly adopt a monorepo specifically to eliminate the coordination cost of publishing and bumping versions of internal packages dozens of times a week. The tradeoff is real: CI times can grow, and without enforced package boundaries a monorepo can quietly become one enormous, tangled package that happens to have folder names. Tools like Nx and Turborepo exist largely to keep that tradeoff manageable as the repo scales.
Importing a sibling package's internal file directly, e.g. import { Button } from "../../ui/src/components/Button/Button".
Import through the package's declared public entry point, e.g. import { Button } from "@example/ui".
Reaching into internals couples you to implementation details that can change at any time without a version bump warning you, since it's all the 'same' local source in a monorepo.
Adopting a full monorepo with Turborepo/Nx for a single small React app 'in case it grows'.
Start with a single-package repo; migrate to a monorepo only once there are genuinely multiple packages sharing code.
Monorepo tooling (workspaces, orchestration, caching, boundary enforcement) adds real setup and maintenance cost that only pays off once code-sharing across multiple packages is an actual, current need.
What is the defining characteristic of a monorepo, beyond just having multiple folders in one repository? A. It only contains JavaScript code B. Packages within it can depend on each other directly by name, with changes visible immediately, without publishing to a registry C. It requires using Turborepo or Nx D. It has exactly one package.json file total
B. Packages within it can depend on each other directly by name, with changes visible immediately, without publishing to a registry — The core benefit of a monorepo is that internal packages can depend on each other's live source directly, skipping the publish/version-bump cycle a polyrepo setup requires for shared code.
Why do dedicated tools like Turborepo or Nx exist on top of npm/pnpm workspaces? A. Workspaces alone can't install dependencies B. Workspaces handle dependency installation/linking but don't understand the inter-package dependency graph for ordering, parallelizing, or caching build tasks C. npm and pnpm workspaces are incompatible with TypeScript D. They are required to define a package.json for each package
B. Workspaces handle dependency installation/linking but don't understand the inter-package dependency graph for ordering, parallelizing, or caching build tasks — Workspaces solve installation and linking; build orchestration tools read each package's dependency graph to run tasks in the correct order, in parallel where safe, and with caching — none of which plain workspaces provide.
Why is importing a sibling package's internal file (bypassing its declared public entry point) considered a mistake? A. It is slower at runtime B. It couples your code to implementation details that can change without warning, breaking the boundary the package's public API is meant to provide C. TypeScript forbids it entirely D. It only works with pnpm, not npm
B. It couples your code to implementation details that can change without warning, breaking the boundary the package's public API is meant to provide — A package's declared exports are its contract with consumers; internal files are free to change at any time, so depending on them directly defeats the purpose of having package boundaries at all.
Sketch (on paper or in a scratch repo) a monorepo with three packages: a shared-types package, a ui component package that depends on it, and a web app that depends on both. Write the package.json 'dependencies' and workspace declarations needed to wire them together with npm workspaces.
Root package.json: "workspaces": ["apps/*", "packages/*"]. packages/ui/package.json depends on "@example/shared-types": "*". apps/web/package.json depends on both "@example/ui": "*" and "@example/shared-types": "*". A single npm install at the root links all three together.
The root package.json needs a "workspaces" array covering both apps/ and packages/ folders; each consuming package.json lists the sibling package name as a dependency.