Next.js Standalone Output in a Rush + pnpm Monorepo: Why Docker Throws MODULE_NOT_FOUND

Next.js standalone builds trace only what they can reach from the app directory. In a Rush/pnpm monorepo the real packages live outside it, so your Docker image boots with missing modules. Here's why, and the one-line fix.

The Symptom

The build is green. .next/standalone is generated. You copy it into a slim Docker image, start node server.js, and the container dies on boot:

Error: Cannot find module 'react'
Require stack:
- /app/node_modules/next/dist/server/...
code: 'MODULE_NOT_FOUND'

It works on your machine, and next start works too. Only the standalone bundle is broken, and only inside a monorepo managed by Rush with pnpm.

How Standalone Output Works

With output: "standalone", Next.js doesn't copy your whole node_modules. It runs a file tracer (@vercel/nft) that walks the require / import graph from your server entry points and copies only the files that are actually reachable into .next/standalone.

That's what makes the image small. It also means the result is only as good as the tracer's view of the filesystem.

Why Rush + pnpm Breaks the Assumption

pnpm doesn't install packages into each project's node_modules. It keeps one content-addressed store and builds a symlink farm. Rush takes this further by hoisting everything into a shared install location:

repo/
├── common/
│   └── temp/
│       └── node_modules/
│           └── .pnpm/
│               ├── next@15.x/node_modules/next        ← real files
│               ├── react@19.x/node_modules/react      ← real files
│               └── ...
└── apps/
    └── web-app/
        └── node_modules/
            ├── next  -> ../../../common/temp/node_modules/.pnpm/next@15.x/...
            └── react -> ../../../common/temp/node_modules/.pnpm/react@19.x/...

Inside apps/web-app/node_modules, every dependency is just a symlink. The real files sit outside the app directory, several levels up.

What Goes Wrong

The tracer's default root is the app directory. So:

  • It follows the symlink, resolves it to a real path under common/temp/..., and finds that path is outside its root.
  • Files outside the trace root are not copied, or are copied to the wrong relative location.
  • The standalone folder ends up with a node_modules that is missing packages, or contains dangling structure.

Nothing fails at build time. The tracer doesn't know it missed anything. The failure only appears when node server.js tries to require() a package that was never copied, which is usually inside Docker, long after CI went green.

The Fix: Move the Trace Root to the Monorepo Root

Next.js lets you widen the tracer's root with outputFileTracingRoot. Point it at the monorepo root so the real pnpm store paths fall inside it:

// next.config.ts
import path from "node:path";
import type { NextConfig } from "next";
 
const config: NextConfig = {
  output: "standalone",
  // apps/web-app -> repo root
  outputFileTracingRoot: path.join(__dirname, "../../"),
};
 
export default config;

Now the tracer sees common/temp/node_modules/.pnpm/... as part of its world, resolves the symlinks to real files, and copies them.

The path layout changes

With a wider root, Next.js mirrors the repo structure inside the standalone folder. Your server entry is no longer at .next/standalone/server.js; it's nested:

.next/standalone/
├── apps/
│   └── web-app/
│       ├── server.js          ← new entry point
│       └── .next/
└── node_modules/              ← resolved dependencies

Update your Dockerfile accordingly:

FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
 
COPY --from=build /repo/apps/web-app/.next/standalone ./
COPY --from=build /repo/apps/web-app/.next/static ./apps/web-app/.next/static
COPY --from=build /repo/apps/web-app/public ./apps/web-app/public
 
CMD ["node", "apps/web-app/server.js"]

The static and public folders are not included in standalone output, and they must be copied to the nested app path, not the root.

Gotchas Worth Checking

  • Set it explicitly. Next.js tries to infer the workspace root from lockfiles. In a Rush repo the lockfile lives in common/config/rush/pnpm-lock.yaml, which the heuristic doesn't recognize, so don't rely on inference.
  • Verify before you ship. After the build, run node apps/web-app/server.js from the standalone folder on a machine without the monorepo's node_modules. If it boots there, Docker will boot.
  • Native and dynamically-required packages. The tracer can't see require(someVariable). If something is still missing after the root fix, add it via outputFileTracingIncludes.
  • Don't "fix" it by copying node_modules wholesale. It works, and it throws away the size benefit that made you pick standalone in the first place.

Takeaway

Standalone tracing assumes the files your app needs live under the app. pnpm symlinks, and Rush's shared install location in particular, break that assumption quietly. Set outputFileTracingRoot to the monorepo root, adjust your Docker paths for the nested layout, and test the bundle in isolation before it reaches an image.

It's one line of config, but only after an afternoon of staring at a container that boots fine everywhere except production.