cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Monorepos

Put several Next.js apps and shared packages in one repository: npm workspaces, a shared design system, transpilePackages, the root, and Turborepo.

By the end, you can
  • 01
    Set up workspacesLay out apps and packages in one repository, and read what npm install links and installs once for all of them.
  • 02
    Share UI and config safelyPoint a package at its TypeScript source, keep "use client" on the files that need it, and share one TypeScript config.
  • 03
    Build and cache one appTell when Next.js compiles a package and when it needs transpilePackages, set the right root, and let Turborepo skip work in CI.

One repository, two apps, one design system

The tea shop now has two Next.js apps: the shop, where customers buy tea, and a dashboard, where the staff check the stock. Both show prices the same way. The Structuring a large app lesson kept shared components like these in one ui/ folder. With two apps, a copy of that folder in each app soon stops matching the other: a fix lands in one and not the other.

A monorepo is one repository that holds several projects. Each project is a package: a folder with a package.json of its own, which gives it a name and lists what it needs. The apps are packages, and so are the parts they share, like the shop's design system. A change to a shared package and to the apps that use it lands in one commit, and every app is built from the same code. Here is the tea shop's repository:

The tea shop's repository, folder by folderText
tea-shop/
  apps/
    dashboard/      the staff dashboard, a Next.js app
    shop/           the shop, a Next.js app
  packages/
    typescript-config/   shared TypeScript settings
    ui/             the design system: Price, AddButton
  package.json      lists the workspaces
  turbo.json        Turborepo's tasks (not run in the recordings)

apps/ holds the two Next.js apps, and packages/ the two shared packages: ui, the design system, and typescript-config, settings both apps share. The repository of the site you're reading is built the same way: its apps/ folder holds this site and an admin dashboard, and its packages/ folder holds the design system both use.

The example below is the whole repository, recorded. The recorder ran npm install once, in the repository's root, then next build and next start in apps/shop, with Next.js 16.4.3. Click through the files.

The tea shop's repository, with the shop builtNext.js
apps/dashboard/app/shop/app/packages/typescript-config/ui/src/
apps/shop/app/page.tsx
import { AddButton, Price } from "@tea/ui"; const teas = [  { name: "Mint tea", cents: 300 },  { name: "Green tea", cents: 400 },]; export default function Home() {  return (    <main>      <h1>Tea shop</h1>      <ul>        {teas.map((tea) => (          <li key={tea.name}>            {tea.name} <Price cents={tea.cents} /> <AddButton name={tea.name} />          </li>        ))}      </ul>    </main>  );}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
Tea shop
  • Mint tea $3.00 Add Mint tea
  • Green tea $4.00 Add Green tea

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Line 1 of apps/shop/app/page.tsx imports AddButton and Price from "@tea/ui". That's a package's name, like "react", not a path to a folder. Line 15 renders both for each tea. The Browser tab shows the page: Mint tea $3.00 Add Mint tea. Open the Terminal tab: first what npm install printed, then the links it made, and then the shop's build, run in apps/shop, with ○ / for the page.

Two editor windows. Left, the tea-shop repository: apps/shop, apps/dashboard, packages/ui and packages/typescript-config. Right, the root's node_modules after npm install: @tea/ui and @tea/typescript-config are links, with arrows to packages/ui and packages/typescript-config, and next, react and react-dom are installed once for every app.The repositorytea-shopapps/shopapps/dashboardpackages/uipackages/typescript-configThe root's node_modulesnode_modules@tea/ui@tea/typescript-confignext, react, react-dom
One repository holds both apps and both shared packages. npm install, run once in the root, puts a link in node_modules for each package (the arrows), and installs Next.js and React once, for every app.
Real-life analogyOne building, two shops, one storeroom

A tea counter and a staff office share one building. Instead of keeping their own boxes of price labels, both take them from one storeroom, from a shelf with a label on it. When the labels change, they change once, for both. A sign in each room says where the shelf is, so nobody keeps a copy.

In real life: The building with both shops in it
In Next.js: The repository
In real life: The tea counter and the staff office
In Next.js: The apps: apps/shop and apps/dashboard
In real life: The shared storeroom, with labelled shelves
In Next.js: The packages: @tea/ui and @tea/typescript-config
In real life: A sign in each shop that points to the storeroom shelf
In Next.js: A link in node_modules that points to the package's folder
The main idea

A monorepo keeps several apps and the packages they share in one repository, installed together. Each app imports a shared package by its name, and the package manager links that name to the package's folder. Next.js builds one app at a time, from that app's folder, and compiles the shared packages it imports as part of it.

Workspaces: one install links the packages

A workspace is a package that lives in the repository, as a folder, instead of coming from the npm registry. The root package.json tells npm which folders hold them:

package.json, at the repository's rootJSON
{  "name": "tea-shop",  "private": true,  "workspaces": [    "apps/*",    "packages/*"  ]} 

Line 3, "private": true, stops npm from ever publishing the root: it's not a package anyone installs. Lines 4 to 7 list the workspaces with patterns: every folder in apps, and every folder in packages. Each of those folders has its own package.json. The shop's is create-next-app's, with two lines added:

apps/shop/package.jsonJSON
{  "name": "shop",  "version": "0.1.0",  "private": true,  "scripts": {    "dev": "next dev",    "build": "next build",    "start": "next start"  },  "dependencies": {    "@tea/ui": "*",    "next": "16.4.3",    "react": "19.3.0",    "react-dom": "19.3.0"  },  "devDependencies": {    "@tea/typescript-config": "*",    "@types/node": "^20",    "@types/react": "^19",    "@types/react-dom": "^19",    "typescript": "^5"  }} 

Line 2 names the app shop. Line 11 asks for @tea/ui, the design system, and line 17 for @tea/typescript-config. The version * means any version: npm finds a workspace with that name and uses it. Running npm install once, in the root, did two things, which the overview's Terminal tab shows:

  • It put a link in the root's node_modules for each workspace. A link (a symbolic link) is a file that points to another folder, so node_modules/@tea/ui opens packages/ui itself. Edit the package, and every app sees the change, with nothing to install again.
  • It installed Next.js, React and TypeScript once, in the root's node_modules, and every package finds them there: Node.js looks for a package in the nearest node_modules folder, then in each folder above, up to the root.
The links npm install made in the root's node_modules, as recorded
Link in node_modulesPoints to
node_modules/@tea/typescript-config../../packages/typescript-config
node_modules/@tea/ui../../packages/ui
node_modules/dashboard../apps/dashboard
node_modules/shop../apps/shop

Each of the four workspaces got a link, the two apps too. The design system's link is node_modules/@tea/ui, and it points two folders up and into packages/ui. pnpm and Yarn have workspaces too. pnpm lists them in a file of its own and writes the dependency differently; this wasn't recorded:

The same workspaces in pnpm (not recorded)YAML
# pnpm-workspace.yaml, at the repository's root
packages:
  - "apps/*"
  - "packages/*"

# In apps/shop/package.json, pnpm writes the dependency like this:
#   "@tea/ui": "workspace:*"

Lines 2 to 4 list the same two folders. With workspace:*, on line 7, pnpm only ever uses the workspace, and never looks for @tea/ui in the registry. To build one app, run its script from the root:

Building one app of the repositoryShell
# From the repository's root:
npm install                     # install everything, link the packages
npm run build --workspace=shop  # run the shop's "build" script, in apps/shop
npm run build -w dashboard      # the same for the dashboard

--workspace=shop, or -w shop for short, runs the build script of the package named shop, in its own folder, which is next build. Here is the dashboard, built from the same repository and the same install:

The same repository, with the dashboard builtNext.js
apps/dashboard/app/shop/app/packages/typescript-config/ui/src/
apps/dashboard/app/page.tsx
import { Price } from "@tea/ui"; const stock = [  { name: "Mint tea", cents: 300, boxes: 12 },  { name: "Green tea", cents: 400, boxes: 3 },]; export default function Stock() {  return (    <main>      <h1>Stock</h1>      <ul>        {stock.map((tea) => (          <li key={tea.name}>            {tea.name}: {tea.boxes} boxes at <Price cents={tea.cents} />          </li>        ))}      </ul>    </main>  );}
Recorded from Next.js 16.4.3
localhost:3000/Stock
Stock
  • Mint tea: 12 boxes at $3.00
  • Green tea: 3 boxes at $4.00

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Line 1 of apps/dashboard/app/page.tsx imports only Price, and line 15 shows each tea's stock with it: Mint tea: 12 boxes at $3.00. The same Price now draws both apps' prices. Change how it formats a price, and both apps change the next time each one is built.

Shared UI and config packages

A shared package is an ordinary folder with a package.json. The design system's says what it's called and which file an import of it gets:

packages/ui/package.jsonJSON
{  "name": "@tea/ui",  "version": "0.0.0",  "private": true,  "exports": {    ".": "./src/index.ts"  },  "peerDependencies": {    "react": "^19"  }} 

Line 2 gives the package its name, @tea/ui. The @tea/ part is a scope: a prefix that groups a team's packages and keeps their names apart from other people's. Lines 5 to 7 are its exports: an import of "@tea/ui" gets ./src/index.ts, a TypeScript file. There's no build step: the app that imports the package compiles it, as part of its own build. Turborepo's docs call this a Just-in-Time package. Lines 8 to 10 say the package needs React without installing a copy of its own, so it uses the app's. The entry file is a barrel: a file that only re-exports other files.

packages/ui/src/index.ts: the barrelTypeScript
export { Price } from "./price";
export { AddButton } from "./add-button";

Line 1 re-exports Price, and line 2 AddButton. Here are both components:

packages/ui/src/price.tsx: no state, no directiveTSX
type PriceProps = { cents: number }; export function Price({ cents }: PriceProps) {  return <span className="tea-price">${(cents / 100).toFixed(2)}</span>;} 
packages/ui/src/add-button.tsx: state, so a directiveTSX
"use client"; import { useState } from "react"; export function AddButton({ name }: { name: string }) {  const [added, setAdded] = useState(false);  return (    <button className="tea-add-button" onClick={() => setAdded(true)} disabled={added}>      {added ? "Added" : `Add ${name}`}    </button>  );} 

Price has no state and no event handlers. Line 4 turns cents into dollars, with the class tea-price, and needs nothing from the browser. AddButton keeps added in state on line 6, and answers clicks, so line 1 of its file is "use client": the file where the client part of the app starts. Everything that file imports goes to the browser too. Next.js still renders the button's HTML on the server (Client Components shows how). A package changes none of this: a directive works the same in a package's file as in the app's.

The recorder looked for each component's class name in the JavaScript files each page loads. In the shop, it found tea-add-button and not tea-price: the button's code reached the browser, and Price's stayed on the server, even though both came through the same barrel. In the dashboard, which only uses Price, it found neither. Where the directive sits matters, though. Try four versions of the design system:

Playground: where does "use client" go in a package?
The tea shop's repository, with this version of the design systemNext.js
apps/dashboard/app/shop/app/packages/typescript-config/ui/src/
packages/ui/src/add-button.tsx
"use client"; import { useState } from "react"; export function AddButton({ name }: { name: string }) {  const [added, setAdded] = useState(false);  return (    <button className="tea-add-button" onClick={() => setAdded(true)} disabled={added}>      {added ? "Added" : `Add ${name}`}    </button>  );}
Recorded from Next.js 16.4.3
# In the repository's root$ npm installadded 34 packages# The links npm made for the workspace packagesnode_modules/@tea/typescript-config -> ../../packages/typescript-confignode_modules/@tea/ui -> ../../packages/uinode_modules/dashboard -> ../apps/dashboardnode_modules/shop -> ../apps/shop # In apps/shop$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (3/3)  Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○  (Static)  prerendered as static content
Try it yourself
On the button's own file

Line 1 of add-button.tsx is where the client part starts. The barrel only re-exports it. The build passes, and the shop's JavaScript holds tea-add-button but not tea-price: Price stays on the server.

Every version was installed with npm install and built in apps/shop with next build from Next.js 16.4.3. Only the design system changes. Reset goes back to the first version.

Each row is one version, built by real Next.js, and what the recorder found in the shop's JavaScript:

What the recorder found in the JavaScript the shop's page loads
VersionDoes the build pass?Is tea-add-button in it?Is tea-price in it?
On the button's own fileYes.YesNo
On the barrelYes.YesYes
Joined into one fileNo. It stops at line 2 of dist/index.js.No page: the build stopped.No page: the build stopped.
Compiled file by fileYes.YesNo

With the directive on the barrel, each export it re-exports starts a client part. This page renders Price, so Price's code went to the browser too: the recorded JavaScript holds tea-price. A barrel that only re-exports Client Components can start with "use client", and exports a page never uses may still be left out of its JavaScript. The trouble is a mixed barrel, with components meant for the server beside client ones. Give those their own entry in the package, and start their files with import "server-only", so a Client Component that imports one stops the build.

The third version's dist/index.js is a recording-only fixture, written by hand: it stands for a build that was set up to join the package into one file and dropped the directive. The build stopped at line 2 of ./packages/ui/dist/index.js, the line that imports useState. The error's import trace names ./apps/shop/app/page.tsx: a Server Component imported it. Whether a build keeps a directive depends on how it's set up, so check the files it writes. The TypeScript compiler (tsc) writes one file for each source file, and keeps a directive on its first line: the fourth version's files are what it writes for this package, and they split the same way as the source. Keep "use client" on the files that need it, don't mix server and client exports in one barrel, and if you build the package, check that each client file still starts with the directive.

Three versions of the design system. Shaded files go to the browser. With the directive on add-button.tsx, only add-button.tsx is shaded, and the shop's JavaScript holds tea-add-button only. With the directive on the barrel, index.ts, price.tsx and add-button.tsx are all shaded, and the JavaScript holds tea-price too, because the page renders Price. Joined into one file that lost the directive, dist/index.js is not shaded, and the build stops.On add-button.tsxuiindex.tsprice.tsxadd-button.tsxtea-add-button onlyOn the barreluiindex.tsprice.tsxadd-button.tsxtea-price tooJoined, lostuidist/index.jsbuild stops
Shaded files go to the browser. On the barrel, each export starts a client part, so Price, which the page renders, went too. In one joined file that lost the directive, the button's useState sits in a Server Component module, and the build stops.

A shared TypeScript config

A config package holds settings, not code. @tea/typescript-config has one file: create-next-app's compiler options, written once.

packages/typescript-config/nextjs.jsonJSON
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }]
  }
}
apps/shop/tsconfig.json: the shared settings, and this app's ownJSON
{  "extends": "@tea/typescript-config/nextjs.json",  "compilerOptions": {    "paths": {      "@/*": ["./*"]    }  },  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],  "exclude": ["node_modules"]} 

Line 2 of each app's tsconfig.json takes every setting from the shared file, by the package's name. Lines 3 to 7 add the @/ alias, and line 8 lists the files to check. Those two stay in the app's file, because a path in a config is read from the folder of the file that writes it: from the shared package, ./* would mean packages/typescript-config. Both recorded builds print Finished TypeScript, so both apps were checked with the shared settings. An ESLint config can be shared the same way, as a package whose eslint.config.mjs each app imports.

How Next.js compiles a local package

Compiling here means turning TypeScript and JSX into plain JavaScript that browsers and Node.js can run. Next.js does it for your app's own files. Code inside node_modules is different: Next.js doesn't compile it by default, because packages from the registry arrive as plain JavaScript. @tea/ui is in node_modules, and it points at a .ts file. So why did the shop build?

Because node_modules/@tea/ui is only a link. Turbopack, Next.js's bundler, follows it to the real file, packages/ui/src/index.ts: a workspace package, inside the repository and outside node_modules. The docs say Turbopack compiles workspace packages automatically, with both the App Router and the Pages Router, and webpack does too, with the App Router. The shop's next.config.ts has nothing about the package, and it built. Now install the same package another way: as a copy inside node_modules, the way a package from a registry arrives.

Playground: does Next.js compile the package?
The tea shop's repository, with the design system installed this wayNext.js
apps/dashboard/app/shop/app/packages/typescript-config/ui/src/
apps/shop/next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,}; export default nextConfig;
Recorded from Next.js 16.4.3
# In the repository's root$ npm installadded 34 packages# The links npm made for the workspace packagesnode_modules/@tea/typescript-config -> ../../packages/typescript-confignode_modules/@tea/ui -> ../../packages/uinode_modules/dashboard -> ../apps/dashboardnode_modules/shop -> ../apps/shop # In apps/shop$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (3/3)  Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○  (Static)  prerendered as static content
Try it yourself
A workspace package

npm linked node_modules/@tea/ui to packages/ui, inside the repository. Turbopack compiles it like the app's own files, with nothing in next.config.ts. The build passes.

Every version was installed with npm install and built in apps/shop with next build from Next.js 16.4.3. Reset goes back to the first version.

The second version moves the design system out of the workspaces, into vendor/ui, and installs it with file:../../vendor/ui. Its .npmrc says install-links=true, which makes npm pack that folder and install it as a copy, as a downloaded package is: the Terminal tab lists no link for @tea/ui any more. That copy stands in for a package your company publishes to a registry as TypeScript source. The build stopped: ./node_modules/@tea/ui/src/index.ts, Unknown module type. Inside node_modules, Turbopack doesn't compile TypeScript, so it found no way to read the file. The third version adds one line:

apps/shop/next.config.ts: compile the copied packageTypeScript
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  transpilePackages: ["@tea/ui"],}; export default nextConfig; 

Line 6 lists @tea/ui in transpilePackages: compile and bundle this package, even inside node_modules. It takes package names, not paths. The build passed, and the shop showed both teas again. The other way out is to build the package to plain JavaScript before you publish it.

Two halves. Left, a workspace package: the shop's page imports @tea/ui, node_modules/@tea/ui is a link, and an arrow follows it to packages/ui/src/index.ts, inside the repository and outside node_modules: compiled. Right, a copy: node_modules/@tea/ui/src/index.ts sits inside node_modules: not compiled, unless transpilePackages lists @tea/ui.A workspace packageapps/shop/app/page.tsxnode_modules/@tea/ui (link)packages/ui/src/index.tscompiledA copy in node_modulesapps/shop/app/page.tsxnode_modules/@tea/ui/src/index.ts: Unknown module typenot compileduntil transpilePackages lists it
Turbopack follows the link to packages/ui, a workspace package, and compiles its TypeScript like the app's own. A copy that lives inside node_modules is taken as ready-to-run JavaScript, so its .ts file stops the build until transpilePackages lists it.

Each row is one kind of package: whether it needs transpilePackages in Next.js 16.4.3, and what showed it.

When a package needs transpilePackages
The packageDoes it need transpilePackages?What shows it
A workspace package, linked by npm, pnpm or Yarn, with TypeScript or JSX insideNo. Turbopack compiles it, with both routers. So does webpack, for the App Router.The shop's build with @tea/ui linked, and no transpilePackages.
A package inside node_modules that ships TypeScript or JSX, like one from a registryYes, or build the package to plain JavaScript first.The copy in node_modules: Unknown module type, then a build that passes once it's listed.
A workspace package, built with webpack, used by the Pages RouterYes, when its source lives outside the app's folder.Not recorded here: the docs say so.
A package in node_modules you want bundled into a Pages Router routeYes: the Pages Router loads node_modules with Node.js's require on the server.Not recorded here: the docs say so.

The repository's root, and one app's standalone folder

Turbopack resolves and compiles files inside one folder, the project's root. A file outside it can only be reached through a symbolic link into an additional root that you name, further down. A small root keeps Turbopack's caches correct and its file watching small. For an app in a monorepo, the root used here is the smallest folder that holds the app and everything it imports: the repository's root, where node_modules, with Next.js in it, and packages/ are. It doesn't have to be the Git repository's root.

In a repository of its own, Next.js finds that folder by itself. In version 16.4.3, it looks up from the app's folder for a workspace marker: a package.json with workspaces, a pnpm-workspace.yaml, or a lerna.json, and takes the outermost one inside the Git repository. Without a marker, it does the same with lockfiles, like package-lock.json (lines 133 to 233 of find-root.ts). The recording machine keeps this repository inside a bigger one, so the recorder names the repository's root for Next.js, the folder it finds by itself in yours. When it finds several lockfiles and no marker, it warns: Warning: Next.js inferred your workspace root, but it may not be correct, and asks you to set turbopack.root (lines 235 to 259). Set it to the app's own folder, and this happens:

The root set to the app's folder: too narrowNext.js
apps/dashboard/app/shop/app/packages/typescript-config/ui/src/
apps/shop/next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  turbopack: {    root: __dirname,  },}; export default nextConfig;
next build failed · Next.js 16.4.3
# In the repository's root$ npm installadded 34 packages# The links npm made for the workspace packagesnode_modules/@tea/typescript-config -> ../../packages/typescript-confignode_modules/@tea/ui -> ../../packages/uinode_modules/dashboard -> ../apps/dashboardnode_modules/shop -> ../apps/shop # In apps/shop$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./appError: Could not find the Next.js package (next/package.json)Resolved from: /my-repo/apps/shop/appFilesystem root used for resolution: /my-repo/apps/shop Possible causes:  - node_modules is being reorganized by a concurrent install (e.g. pnpm adding a package with a `next` peer dependency). This is transient and should clear once the install completes.  - node_modules/next was removed, renamed, or has a broken symlink.  - The workspace root is incorrect — see turbopack.root in the Next.js config docs for how to configure it.  - In a monorepo, the Next.js package may only exist in a directory above the closest directory containing a package manager lockfile. The workspace root is detected by locating the nearest package manager lockfile.  - Next.js is installed globally rather than as a project dependency. This is not supported; install it locally. Note: To ensure a hermetic build and a portable cache, files outside of the workspace root are not compiled. https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory     at <unknown> (Filesystem root used for resolution: /my-repo/apps/shop)    at <unknown> (https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory)

Lines 6 to 8 set turbopack.root to __dirname, the folder next.config.ts is in: apps/shop. The build stopped with Could not find the Next.js package (next/package.json). The next lines say why: Filesystem root used for resolution: /my-repo/apps/shop, and files outside it are not compiled. Next.js is in /my-repo/node_modules, one level above. (/my-repo is how the recording shows the repository's folder.) In Next.js 16.4.3, turbopack.root and outputFileTracingRoot are one setting: set either, and Next.js gives the other the same value. If you set it, set it to a folder that holds the app, node_modules and every package it imports: here, the repository's root.

A package outside the root: additional roots

Sometimes a package lives outside that folder, like a design system cloned next to the repository and linked in with npm link. Since Next.js 16.4.0, the experimental option turbopackAdditionalRoots names more folders that symbolic links may point into (lines 519 to 531 of config-shared.ts). This wasn't recorded:

apps/shop/next.config.ts, with a linked package outside the repository (not recorded)TypeScript
import path from "node:path";import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  experimental: {    turbopackAdditionalRoots: {      designSystem: { path: path.join(__dirname, "../../../tea-design-system") },    },  },}; export default nextConfig;

Lines 8 to 10 name one more root, designSystem, the folder next to the repository. The docs set out its limits:

  • It's only crossed at a symbolic link, like node_modules/@tea/ui pointing there. A relative import that climbs out of the root, like ../../../tea-design-system/src/price, still isn't supported.
  • An additional root can't sit inside the root or another additional root, or hold one of them.
  • The link and the folder must both be on the build machine before next build starts; a CI system that copies only the repository won't have them.
  • With standalone output, files from an additional root that aren't bundled are copied into a folder named after the root.
Tracing, and the standalone folder

Tracing is how next build works out which files the server needs at runtime, to copy them into the standalone folder (Self-hosting shows it in a plain project). It looks inside the root, and inside any additional roots.

Here is the shop with standalone output, and the root named as the docs show it for a monorepo:

Standalone output from the repositoryNext.js
apps/dashboard/app/shop/app/packages/typescript-config/ui/src/
apps/shop/next.config.ts
import path from "node:path";import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  output: "standalone",  outputFileTracingRoot: path.join(__dirname, "../.."),}; export default nextConfig;
Recorded from Next.js 16.4.3
# In the repository's root$ npm installadded 34 packages# The links npm made for the workspace packagesnode_modules/@tea/typescript-config -> ../../packages/typescript-confignode_modules/@tea/ui -> ../../packages/uinode_modules/dashboard -> ../apps/dashboardnode_modules/shop -> ../apps/shop # In apps/shop$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (3/3)  Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○  (Static)  prerendered as static content # What .next/standalone holds: 1219 files, 45.3 MB (deeper folders added up).next/standalone/apps/shop/  550.2 kB, 90 files.next/standalone/node_modules/@emnapi/runtime/  66.5 kB, 4 files.next/standalone/node_modules/@img/colour/  46.7 kB, 3 files.next/standalone/node_modules/@img/sharp-darwin-arm64/  281.3 kB, 3 files.next/standalone/node_modules/@img/sharp-libvips-darwin-arm64/  18.2 MB, 6 files.next/standalone/node_modules/@img/sharp-wasm32/  9.3 MB, 5 files.next/standalone/node_modules/@next/env/  11.0 kB, 2 files.next/standalone/node_modules/@swc/helpers/  30.7 kB, 5 files.next/standalone/node_modules/client-only/  467 bytes, 2 files.next/standalone/node_modules/detect-libc/  11.4 kB, 5 files.next/standalone/node_modules/next/  14.6 MB, 993 files.next/standalone/node_modules/react-dom/  1.4 MB, 12 files.next/standalone/node_modules/react/  22.1 kB, 9 files.next/standalone/node_modules/semver/  68.7 kB, 47 files.next/standalone/node_modules/sharp/  631.5 kB, 29 files.next/standalone/node_modules/styled-jsx/  22.3 kB, 4 files $ node .next/standalone/apps/shop/server.js▲ Next.js 16.4.3- Local:         http://127.0.0.1:3000- Network:       http://127.0.0.1:3000✓ Ready✓ Running next.config took 4ms

Line 7 asks for standalone output, and line 8 sets the root two folders up from apps/shop: the repository. In the Terminal tab, the folder list after the build copies the repository's layout: .next/standalone/apps/shop/ holds the app, and .next/standalone/node_modules/ holds only the packages the server needs, like next and react-dom. There's no packages/ folder: the design system was compiled into the shop's own server code. So this recording shows the layout and the packages installed in the root, not a file copied from packages/: nothing there is read at runtime without being bundled. The server is apps/shop/server.js inside that folder, and the recorder started it with node .next/standalone/apps/shop/server.js. It printed Ready, and / answered 200, as the Network tab shows. A Dockerfile for the shop copies the same paths:

The shop's last Docker stage, in a monorepo (not run)Dockerfile
# The shop's last Docker stage. The build ran in apps/shop with output: "standalone".FROM node:24-slim AS runnerWORKDIR /repoENV NODE_ENV=productionCOPY --from=builder /repo/apps/shop/.next/standalone ./COPY --from=builder /repo/apps/shop/.next/static ./apps/shop/.next/staticEXPOSE 3000CMD ["node", "apps/shop/server.js"]

Line 5 copies the standalone folder into /repo. Line 6 adds the browser's files, .next/static, beside the app's server, because the standalone folder leaves them out. Line 8 starts apps/shop/server.js. An earlier stage named builder, not shown, runs npm ci in the root and npm run build -w shop.

Turborepo: tasks, caching and CI

With two apps, every change starts the same question: which apps must be built and checked again? Building all of them works, but it gets slow as the repository grows. Turborepo (turbo) is a tool that runs the same script, like build, in every package that has it: in the right order, at the same time where it can, and only when something changed. None of the recordings here ran Turborepo: its config and commands below follow Turborepo's docs. You add it with npm install turbo --save-dev in the root, and describe the tasks, the scripts it runs, in a turbo.json file:

turbo.json, at the repository's root (not run)JSON
{  "$schema": "https://turborepo.dev/schema.json",  "tasks": {    "build": {      "dependsOn": ["^build"],      "outputs": [".next/**", "!.next/cache/**", "!.next/dev/**"],      "env": ["SHOP_API_URL"]    },    "lint": {},    "dev": {      "cache": false,      "persistent": true    }  }}

Lines 4 to 8 describe the build task. Line 5, "^build", runs the build task of each package an app depends on first, for a package that has a build step of its own. The design system here has none, so there's nothing to wait for. Line 6 lists the task's outputs: the files to save, here .next/ without its cache and dev folders, as Turborepo's docs write it for Next.js. Line 7 lists an environment variable the build reads. Each row is one key:

The keys in the tea shop's turbo.json
KeyWhat it does
tasks.buildNames a task. turbo run build runs the build script of each package that has one.
dependsOn: ["^build"]Runs the build task of each package this one depends on first. ^ means "in my dependencies".
outputsThe files to save in the cache and put back on a cache hit: here .next/ without its own cache folder.
envEnvironment variables whose values change the result, so a change in them misses the cache. NEXT_PUBLIC_* are added for a Next.js app.
cache: false and persistent: trueFor dev: never cache it, and know it keeps running.

The root's scripts then call turbo instead of npm's workspace flags:

package.json, at the root, with Turborepo (not recorded)JSON
{  "name": "tea-shop",  "private": true,  "workspaces": ["apps/*", "packages/*"],  "scripts": {    "build": "turbo run build",    "dev": "turbo run dev",    "lint": "turbo run lint"  }}

Line 6 makes npm run build run turbo run build: the build script of the shop and the dashboard, side by side.

How the cache decides

Before it runs a task, Turborepo computes a fingerprint of the task's inputs: a short hash of the package's files that Git tracks, its package.json, the lockfile entries that affect it, turbo.json, and the values of the environment variables in env. For a Next.js app it adds NEXT_PUBLIC_* variables by itself, since Next.js puts them into the browser's code (Server-only code and environment variables). If that fingerprint has been seen, Turborepo skips the task and puts back the saved outputs and the logs it printed: a cache hit. It keeps the cache in .turbo/cache, on your machine.

A Remote Cache shares that cache between machines, so CI can reuse a build made on a laptop, or on another branch. On October 30, 2026, Turborepo's docs say Vercel Remote Cache is free on all plans, even for apps not hosted on Vercel, and that you can run your own cache server instead, through its published Remote Cache API. A CI job gives turbo the cache's token and team, and builds only what a change affects:

.github/workflows/ci.yml (not run)YAML
# .github/workflows/ci.ymlname: CIon: pull_requestjobs:  build:    runs-on: ubuntu-latest    env:      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}      TURBO_TEAM: ${{ vars.TURBO_TEAM }}    steps:      - uses: actions/checkout@v4        with:          fetch-depth: 0 # --affected compares with main, so it needs the history      - uses: actions/setup-node@v4        with:          node-version: 24      - run: npm ci      - run: npx turbo run build lint --affected

Lines 8 and 9 pass the Remote Cache's token and team to every step, from the repository's secrets. Line 18 runs build and lint with --affected: only in the packages changed since the main branch, and the packages that depend on them. Change packages/ui, and both apps are affected; change only the dashboard, and the shop is left alone.

A laptop window runs turbo run build: shop#build and dashboard#build run, and an arrow sends their outputs to a cache card, the Remote Cache, in the middle. An arrow from the Remote Cache goes to a CI window, where the same tasks, with the same inputs, are cache hits: .next is restored instead of built.laptopshop#build: randashboard#build: ranoutputs savedcacheRemote CacheCIshop#build: hitdashboard#build: hit.next restoredturbo run buildthe same inputs
The first run builds each app and saves its outputs and logs under the task's fingerprint. Another machine whose inputs give the same fingerprint, like CI, restores them from the Remote Cache instead of building again.
A cache hit is only as good as its inputs

Turborepo assumes a task gives the same output for the same inputs. If next build reads an environment variable that turbo.json doesn't list, a cache hit can bring back a build made with an old value. List every variable the build reads in env.

Builds as it is, or stops the build?

Each card is one of the repository's recorded builds. Sort them: does next build in apps/shop pass, or stop?

Builds as it is, or stops the build?
  • The shop imports @tea/ui, a workspace package whose exports point at ./src/index.ts.
  • The same TypeScript package is a copy inside node_modules, and next.config.ts has no transpilePackages.
  • The same copy, with transpilePackages: ["@tea/ui"].
  • A build joined both components into dist/index.js and dropped "use client", and a Server Component imports it.
  • The package is compiled file by file, and dist/add-button.js still starts with "use client".
  • The barrel, src/index.ts, starts with "use client".
  • A new shared component calls useState, has no "use client", and the shop's page renders it.
  • The shop's next.config.ts sets turbopack.root to __dirname, the app's own folder.
Try it yourself
0 of 8 correct

Sort each change to the tea shop's repository by what the recorded next build did.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A monorepo is one big app." No. The shop and the dashboard are separate Next.js apps, built and deployed one at a time. They share packages, not a build.
  • "A shared TypeScript package always needs transpilePackages." No. A workspace package is compiled automatically: the shop built without it. A copy inside node_modules needs it.
  • "Putting "use client" on any barrel is harmless." It builds, but each export becomes the start of a client part: the shop sent Price's code to the browser. That's fine for a barrel of Client Components only; keep server code in an entry of its own.
  • "A build step always keeps the directive." Not always: it depends on how the build is set up. The recorded fixture stands for one that dropped it, and the build stopped. Check the files your build writes.
  • "Setting turbopack.root to the app's folder silences the warning safely." No. In a monorepo it cuts off the root's node_modules, and the build can't find Next.js.
  • "Turborepo builds the apps." No. It runs each package's own build script, which is next build, and saves or restores its outputs.
  • "A cache hit means the build is correct." Only when every input is listed. An environment variable missing from env can bring back a build made with an old value.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upRead a link npm made

These are the links the overview's npm install made. Where does node_modules/@tea/ui point?

Starter codeText
# The links npm made for the workspace packages
node_modules/@tea/typescript-config -> ../../packages/typescript-config
node_modules/@tea/ui -> ?
node_modules/dashboard -> ../apps/dashboard
node_modules/shop -> ../apps/shop

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upGive the dashboard the design system

    The dashboard wants to import Price from @tea/ui. Which line does its package.json need?

    Starter codeJSON
    {
      "name": "dashboard",
      "version": "0.1.0",
      "private": true,
      "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start"
      },
      "dependencies": {
        "next": "16.4.3",
        "react": "19.3.0",
        "react-dom": "19.3.0"
      },
      "devDependencies": {
        "@tea/typescript-config": "*",
        "@types/node": "^20",
        "@types/react": "^19",
        "@types/react-dom": "^19",
        "typescript": "^5"
      }
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFix the new shared component

      Someone adds Quantity to the design system, re-exports it from the barrel, and renders it in the shop. The build stops: You're importing a module that depends on useState into a React Server Component module. Which line is missing from packages/ui/src/quantity.tsx?

      Starter codeTSX
      import { useState } from "react";
      
      export function Quantity() {
        const [boxes, setBoxes] = useState(1);
        return <button onClick={() => setBoxes(boxes + 1)}>Boxes: {boxes}</button>;
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeBuild with the copied package

        The design system now arrives as a copy in node_modules, and the shop's build stops with Unknown module type on ./node_modules/@tea/ui/src/index.ts. What do you add to this next.config.ts?

        Starter codeTypeScript
        import type { NextConfig } from "next";
        
        const nextConfig: NextConfig = {
          cacheComponents: true,
          partialPrefetching: true,
          // Add one line here
        };
        
        export default nextConfig;
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFix the root

          This apps/shop/next.config.ts stops the build with Could not find the Next.js package. Where should the root point, if anywhere?

          Starter codeTypeScript
          import type { NextConfig } from "next";
          
          const nextConfig: NextConfig = {
            cacheComponents: true,
            partialPrefetching: true,
            turbopack: {
              root: __dirname,
            },
          };
          
          export default nextConfig;
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeStart the standalone shop

            The shop is built with output: "standalone" and the root set to the repository. From the shop's folder, which command starts its server?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, think about where each file really is, and which tool reads it.

              The Monorepos quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does npm do with this root package.json when you run npm install?

                Read the code, then predictJSON
                {
                  "name": "tea-shop",
                  "private": true,
                  "workspaces": [
                    "apps/*",
                    "packages/*"
                  ]
                }
                

                Choose an answer to see the explanation.

              2. Question 2 of 7The shop's page imports @tea/ui, whose exports point at ./src/index.ts. Why does next build compile that TypeScript without transpilePackages?

                Choose an answer to see the explanation.

              3. Question 3 of 7Which version of the design system sends the code of Price, which has no state, to the browser?

                Choose an answer to see the explanation.

              4. Question 4 of 7A build that was set up wrongly joined the whole design system into one file, dist/index.js, and dropped the "use client" from the button's file. What happens when the shop's Server Component page imports it?

                Read the code, then predictJavaScript
                import { jsx, jsxs } from "react/jsx-runtime";
                import { useState } from "react";
                
                function Price({ cents }) {
                  return jsxs("span", { className: "tea-price", children: ["$", (cents / 100).toFixed(2)] });
                }
                
                function AddButton({ name }) {
                  const [added, setAdded] = useState(false);
                  return jsx("button", { className: "tea-add-button", onClick: () => setAdded(true), disabled: added, children: added ? "Added" : `Add ${name}` });
                }
                
                export { AddButton, Price };
                

                Choose an answer to see the explanation.

              5. Question 5 of 7In the button's own source file, where must the directive be?

                Read the code, then predictTSX
                "use client";
                
                import { useState } from "react";
                
                export function AddButton({ name }: { name: string }) {
                  const [added, setAdded] = useState(false);
                  return (
                    <button className="tea-add-button" onClick={() => setAdded(true)} disabled={added}>
                      {added ? "Added" : `Add ${name}`}
                    </button>
                  );
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 7The shop's next.config.ts sets turbopack: { root: __dirname }. What does the recorded build print?

                Choose an answer to see the explanation.

              7. Question 7 of 7In turbo.json, what does "dependsOn": ["^build"] on the build task do?

                Choose an answer to see the explanation.

              Key takeaways

              • A monorepo holds several apps and shared packages in one repository. The root package.json lists the workspaces, and each package has its own package.json with a name.
              • One npm install in the root links each workspace into node_modules and installs Next.js and React once. Apps import a package by its name.
              • A package can point at its TypeScript source. Turbopack compiles workspace packages by itself; a copy inside node_modules needs transpilePackages.
              • "use client" works in a package as in an app: on the files that need it, not on a barrel that mixes server and client code, and still there after the package is built.
              • The root must hold the app, node_modules and the packages it imports: here, the repository's root. Next.js finds it from a workspace marker or a lockfile; a package outside it needs an additional root, reached through a symbolic link. Standalone output then sits in .next/standalone/apps/shop.
              • Turborepo runs each package's scripts in order and in parallel, and restores outputs whose inputs haven't changed, on your machine or from a Remote Cache in CI.

              Remember the one-liner.
              One repository, one install, many apps: packages are shared by name, compiled by the app that imports them, and cached by their inputs.

              Read it in Next.js's own code

              Next.js 16.4.3 finds the root in lines 98 to 143 of find-root.ts: the lockfile names, the workspace markers, and the search that prefers a marker. The Under the hood stage reads how Turbopack resolves and compiles modules.

              Coming next: Multi-zones. Several Next.js apps from one repository, served together on one address, so a blog team can deploy without the shop.

              CompleteFrontend Clear concepts. Working examples.