03 · Monorepos¶
Once an organisation has a marketing site, a product app, an admin tool and a shared design system, keeping them in separate repositories means publishing packages to share a button. A monorepo puts them in one repository with workspace tooling so apps import shared packages directly from source.
A typical layout¶
acme/
├── package.json ← workspaces declared here
├── package-lock.json
├── apps/
│ ├── web/ ← Next.js marketing site
│ └── dashboard/ ← Next.js product app
└── packages/
├── ui/ ← shared React components
├── db/ ← Drizzle schema + queries (server-only)
├── config-eslint/
└── config-typescript/
{
"name": "acme",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "npm run build --workspaces --if-present",
"dev": "npm run dev --workspace=apps/dashboard"
}
}
npm, pnpm, Yarn and Bun all support workspaces. pnpm is popular for monorepos because
its strict node_modules layout surfaces undeclared dependencies.
A shared UI package¶
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
},
"peerDependencies": { "react": "^19" }
}
export function Button({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button {...props} style={{ padding: "0.5rem 1rem", borderRadius: 8 }}>
{children}
</button>
);
}
Using it from an app:
{ "dependencies": { "@acme/ui": "*", "next": "16.3.6", "react": "19.2.8", "react-dom": "19.2.8" } }
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui"], // compile the package's TypeScript/JSX source
};
export default nextConfig;
import { Button } from "@acme/ui/button";
export default function Home() {
return <Button type="button">Create report</Button>;
}
The package exports source (.tsx), not compiled JavaScript, so there's no build
step for it; transpilePackages tells Next.js to compile it as part of the app. The
alternative — building each package to dist/ — is better when packages are also
published to npm or consumed by non-Next tools.
Server and client in shared packages¶
Directives work across package boundaries. A package component that uses state needs
"use client" at the top of its file, exactly as inside an app. Keep server-only
packages (like @acme/db) guarded with import "server-only" so no app accidentally
bundles database code for the browser. Separate entry points per component (the
exports map above) avoid pulling every component — and every "use client" boundary —
into a page that needs one button.
Turbopack and the workspace root¶
Turbopack needs to know the monorepo root to resolve files outside the app directory. It usually infers it from lock files; if it guesses wrong (for example with several lock files on the machine), set it explicitly:
import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui"],
turbopack: { root: path.join(__dirname, "..", "..") },
};
export default nextConfig;
For standalone output in a monorepo, outputFileTracingRoot plays the same role for
file tracing, so files from packages/ are included.
Task runners and caching¶
Building five apps and ten packages on every commit is slow. Turborepo and Nx understand the dependency graph between workspaces and:
- run tasks in dependency order and in parallel;
- cache task outputs keyed by input file hashes, so an unchanged app isn't rebuilt;
- optionally share that cache across CI machines and developers.
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] },
"lint": {},
"test": { "dependsOn": ["^build"] }
}
}
"^build" means "build my dependencies first". Excluding .next/cache keeps the cached
artifact small. Check the Turborepo docs for the current schema URL and options.
Next.js's own build cache (.next/cache, including Turbopack's file-system cache) should
also be persisted between CI runs; the Next.js docs have a CI build caching guide per
provider.
How It Actually Works¶
Workspaces are a package-manager feature: during install, each workspace package is
symlinked into node_modules (node_modules/@acme/ui → packages/ui), so imports
resolve like any dependency. Next.js, by default, doesn't compile code from
node_modules — it expects published packages to be plain JavaScript.
transpilePackages adds the listed packages to the set of modules run through the
TypeScript/JSX transform and the server/client directive handling. Turbopack resolves
the symlinks to real paths and must be allowed to read them, which is why it needs a
root that contains both the app and the packages. Task runners hash each package's
inputs (source files, dependencies' outputs, env vars you declare) to decide if a cached
output can be replayed instead of re-running the build.
Common mistakes¶
- Forgetting
transpilePackagesfor source-only packages — syntax errors on JSX or TS. - Several React copies (each app and package with its own
react) — hooks break. Make React a peer dependency of packages. - One giant barrel export from
@acme/uiwith"use client"at the top — every page imports all client components. - Turbopack root inferred incorrectly — "module not found" for files outside the app.
- Not declaring env vars in the task runner's config, so cached builds reuse outputs built with different environment values.
Exercise¶
- Create a workspace with
apps/web(create-next-app) andpackages/uiexporting a Button and a Card from source. Use both in the app withtranspilePackages. - Add
packages/dbwith aserver-onlyguard and try importing it into a Client Component in the app. Read the error. - Add Turborepo, run
turbo buildtwice, and note which tasks were cached the second time.