React
Next.js in a monorepo: the structure that holds
A Next.js monorepo pays off when apps share components and config. The minimum structure with pnpm, the packages worth extracting and the real costs.
Key points
- One lockfile and one React version: two copies break hooks at runtime.
- `apps/` holds deployments, `packages/` holds shared code with an npm name.
- The UI package must be transpiled or compiled: Next does not read sources outside its folder.
A Next.js monorepo pays off when two applications genuinely share components, tokens and configuration. The minimum structure is apps/ for deployments, packages/ for shared code and a single lockfile at the root.
The minimum structure
repo/
apps/
web/ # app principale, un deploy
app/
next.config.ts
package.json
docs/ # seconda app, stesso design system
packages/
ui/ # componenti condivisi
config/ # eslint, tsconfig, tailwind
lib/ # utility pure
package.json
pnpm-workspace.yaml
turbo.json
The workspace is declared in pnpm-workspace.yaml; every folder under apps/ and packages/ carries a package.json with an npm name. Next.js does not know it lives in a monorepo: the module resolver is what links the packages.
What to share and what to keep apart
| Resource | Where | Why |
|---|---|---|
| Tokens and UI primitives | packages/ui | One place to update |
| ESLint and tsconfig | packages/config | Apps inherit, they do not copy |
| Shared domain logic | packages/lib | Pure functions, testable alone |
| Routes and layouts | apps/web/app | They are URLs, not reusable code |
A package that ships TSX files must be listed in transpilePackages, otherwise the Next build ignores files outside its own folder. If the package is also used outside Next, compile it and publish the output.
// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@repo/ui", "@repo/lib"],
};
export default nextConfig;
The command that exposes the problem
# una sola versione di React in tutto il workspace
pnpm why react
# build della sola app: rivela gli import fuori dal pacchetto
pnpm --filter @repo/web build
If pnpm why react prints two versions, hooks break with “Invalid hook call” at runtime and the message never says why. The cause is a package that declares React as a normal dependency instead of a peer.
The record I use as an example
The EneUI catalogue indexes 111,985 components and 1,556 blocks from 1,472 libraries (1 November 2026). A complete block installs as source into packages/ui: dashboard-01, with the dashboard category and the primitives in card.
Where a monorepo costs
- Remote build needs a cache token and a configuration nobody maintains.
- The
standaloneoutput must ship with the root lockfile, not the app one. - A shared package without tests becomes a regression point for every app.
- The Next upgrade slips, because it must pass on every app before the merge.
Known limit: Next cannot resolve a package that exposes ESM only, with no exports field. The build fails with a syntax error that points at the wrong file, and the fix lives in the package package.json.
For the structure of a single app see App Router structure. For caching that survives the request see the four cache layers; the framework comparison for a marketing site is Next.js vs Vite.
Frequently asked questions
Do I need Turborepo for a Next.js monorepo?
No. A pnpm workspace is enough for two apps and about ten packages. Turborepo adds task caching and a dependency graph once builds pass five minutes.
Where does the design system go?
In packages/ui. It needs an npm name, a package.json and no dependency on the app that consumes it.
How do I avoid two copies of React?
Declare React and React DOM as peerDependencies in shared packages and as dependencies only in the apps. Then check with pnpm why react.
One app or several apps?
One app as long as sharing concerns components only. A second app arrives when it has its own deploy, domain or audience.
Does the Vercel deploy work with no configuration?
With pnpm yes, if the lockfile sits at the root and the build command filters the package. With npm and nested folders you must set the project root directory.