Monorepos
Put several Next.js apps and shared packages in one repository: npm workspaces, a shared design system, transpilePackages, the root, and Turborepo.
- 01Set up workspacesLay out apps and packages in one repository, and read what npm install links and installs once for all of them.
- 02Share UI and config safelyPoint a package at its TypeScript source, keep "use client" on the files that need it, and share one TypeScript config.
- 03Build 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:
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.
apps/shop/app/page.tsximport { 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> );}- Mint tea $3.00
- Green tea $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/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.
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.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/shopandapps/dashboard - In real life: The shared storeroom, with labelled shelves
- In Next.js: The packages:
@tea/uiand@tea/typescript-config - In real life: A sign in each shop that points to the storeroom shelf
- In Next.js: A link in
node_modulesthat points to the package's folder
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:
{ "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:
{ "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_modulesfor each workspace. A link (a symbolic link) is a file that points to another folder, sonode_modules/@tea/uiopenspackages/uiitself. 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 nearestnode_modulesfolder, then in each folder above, up to the root.
| Link in node_modules | Points 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:
# 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:
# 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:
apps/dashboard/app/page.tsximport { 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> );}- 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.
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.
apps/shop/next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true,}; export default nextConfig;# 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 contentnpm 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.
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:
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.
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.
| The package | Does it need transpilePackages? | What shows it |
|---|---|---|
| A workspace package, linked by npm, pnpm or Yarn, with TypeScript or JSX inside | No. 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 registry | Yes, 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 Router | Yes, 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 route | Yes: 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:
apps/shop/next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, turbopack: { root: __dirname, },}; export default nextConfig;# 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:
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/uipointing 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 buildstarts; 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 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:
apps/shop/next.config.tsimport 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;# 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 4msLine 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. 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:
{ "$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:
| Key | What it does |
|---|---|
tasks.build | Names 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". |
outputs | The files to save in the cache and put back on a cache hit: here .next/ without its own cache folder. |
env | Environment 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: true | For dev: never cache it, and know it keeps running. |
The root's scripts then call turbo instead of npm's workspace flags:
{ "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.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 --affectedLines 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.
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.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?
- The shop imports
@tea/ui, a workspace package whoseexportspoint 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.rootto__dirname, the app's own folder.
Sort each change to the tea shop's repository by what the recorded next build did.
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_modulesneeds 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
buildscript, which isnext 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
envcan 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.
These are the links the overview's npm install made. Where does node_modules/@tea/ui point?
# 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../../packages/ui. From node_modules/@tea, two folders up is the repository's root, and from there packages/ui. The overview's Terminal tab lists exactly that link.
The dashboard wants to import Price from @tea/ui. Which line does its package.json need?
{
"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"
}
}
"@tea/ui": "*", first in dependencies. With npm, * takes the workspace named @tea/ui. The recorded dashboard has this line, and its page shows Mint tea: 12 boxes at $3.00.
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?
import { useState } from "react";
export function Quantity() {
const [boxes, setBoxes] = useState(1);
return <button onClick={() => setBoxes(boxes + 1)}>Boxes: {boxes}</button>;
}
"use client";
import { useState } from "react";
export function Quantity() {
const [boxes, setBoxes] = useState(1);
return <button onClick={() => setBoxes(boxes + 1)}>Boxes: {boxes}</button>;
}
"use client"; on line 1. Without it, the shop's Server Component page imports a module that uses useState, and the recorded build stops at ./packages/ui/src/quantity.tsx:1:10. With the line, it builds, and the shop's JavaScript holds Boxes: .
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?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
// Add one line here
};
export default nextConfig;
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
transpilePackages: ["@tea/ui"],
};
export default nextConfig;
transpilePackages: ["@tea/ui"]. Next.js doesn't compile code inside node_modules unless you list the package. The recorded build with this line passes.
This apps/shop/next.config.ts stops the build with Could not find the Next.js package. Where should the root point, if anywhere?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
turbopack: {
root: __dirname,
},
};
export default nextConfig;
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
Remove turbopack.root, or point it at the repository's root, like path.join(__dirname, "../.."). The recorded shop builds with no root set, and the standalone version builds with outputFileTracingRoot set two folders up.
The shop is built with output: "standalone" and the root set to the repository. From the shop's folder, which command starts its server?
node .next/standalone/apps/shop/server.js, from apps/shop. The recorder started exactly that and got a 200 for /. In a container that copies the folder to /repo, it's node apps/shop/server.js.
Check your understanding
For each question, think about where each file really is, and which tool reads it.
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.
Question 2 of 7The shop's page imports
@tea/ui, whoseexportspoint at./src/index.ts. Why doesnext buildcompile that TypeScript withouttranspilePackages?Choose an answer to see the explanation.
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.
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 predictJavaScriptimport { 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.
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.
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.
Question 7 of 7In turbo.json, what does
"dependsOn": ["^build"]on thebuildtask do?Choose an answer to see the explanation.
Key takeaways
- A monorepo holds several apps and shared packages in one repository. The root
package.jsonlists the workspaces, and each package has its ownpackage.jsonwith a name. - One
npm installin the root links each workspace intonode_modulesand 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_modulesneedstranspilePackages. "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_modulesand 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.
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.