</>CodeWithKarani

serverExternalPackages Silently Fails Under pnpm and Turbopack

Karani GeoffreyKarani Geoffrey7 min read

You have a package that must not be bundled. Maybe it ships a native .node addon, maybe it does dynamic require at runtime, maybe it just breaks when a bundler touches it. So you do the correct thing and list it in serverExternalPackages in next.config.js. Under npm this works. You switch a project to pnpm, run the exact same build, and the package gets bundled anyway, producing a runtime crash that looks nothing like a package-manager problem.

You will stare at the config. It is right. You will stare at the package. It is fine. You will not find the answer in either place, because the cause is neither. It is the shape of node_modules that pnpm builds, and an assumption Turbopack makes about that shape.

This is a real, tracked interaction, not a mistake in your setup. The same serverExternalPackages entry that works under npm and yarn silently fails to externalize under pnpm plus Turbopack, and the failure surfaces as a confusing bundling or runtime error with no obvious link to the package manager.

Under pnpm, a package can only be externalized if it is resolvable from your project root. pnpm's symlinked layout hides transitive dependencies from the root, so Turbopack cannot resolve them, decides it cannot make them external, and bundles them instead. Two workarounds:

  • Hoist the specific package with public-hoist-pattern[]=your-package in .npmrc, then reinstall, so it resolves from the top-level node_modules.
  • Or build with webpack instead of Turbopack (run next build without the Turbopack flag), which does not make the same resolution assumption.

The same config is fine under npm or yarn, so do not rewrite it. This is package-manager-specific.

The symptom: listed as external, bundled anyway

Your config looks like this, and it is correct:

// next.config.js
const nextConfig = {
  serverExternalPackages: ['some-native-pkg'],
};
module.exports = nextConfig;

Under npm the package stays external and everything runs. Under pnpm with Turbopack, the package gets pulled into the bundle. What you actually see depends on the package: a native addon throws because its .node file was not copied next to the transformed code, a package that does dynamic require throws a module-not-found at runtime, or you get a build-time error complaining that the request could not be resolved from the project directory. None of these mention pnpm, which is exactly why this eats an afternoon.

Why pnpm and Turbopack disagree about resolution

npm and yarn build a flat node_modules. Nearly every package, direct or transitive, ends up hoisted to the top level, so from your project root you can resolve almost anything by name. Turbopack's externalization leans on this: to mark a package external, it needs to confirm that the runtime will be able to require it from the output directory, which it checks by resolving the package from the project root. Under a flat layout that check passes for basically everything.

pnpm builds a strict, symlinked layout instead. The real packages live in a content-addressed store under node_modules/.pnpm/, and your top-level node_modules contains symlinks only to your direct dependencies. A package that you never listed in your own package.json, but that arrives as a child dependency of something you did install, is not linked at the top level at all. It exists, deep inside .pnpm, but it cannot be resolved by name from your project root.

Now the two assumptions collide. Turbopack tries to resolve the external package from the project directory to confirm it can stay external. Under pnpm, if that package is a transitive dependency, the resolution fails. Turbopack concludes it cannot safely externalize something it cannot resolve, so it does the only other thing it can: it bundles it. Your config was honoured right up until the resolution check, which pnpm quietly failed.

npm / yarn: flat, resolves from root node_modules/ some-native-pkg/ <- direct link at root deep-native-dep/ <- also hoisted to root Turbopack resolves it -> keeps external pnpm: symlinked, hidden from root node_modules/ some-native-pkg -> .pnpm/... (link) deep-native-dep: only in .pnpm, no root link Turbopack cannot resolve it -> bundles it The config was honoured; the resolution check failed silently under pnpm. Fix direction: make the package resolvable from root (hoist), or use a bundler that does not require it (webpack).
Same config, same package. The only variable is whether the package can be resolved from the project root, and that is exactly what pnpm's layout changes.

Workaround 1: hoist the package with public-hoist-pattern

The cleanest fix that keeps Turbopack is to make the offending package resolvable from the top level without abandoning pnpm's isolation everywhere. pnpm supports targeted hoisting through .npmrc.

Step 1: add the pattern to .npmrc

# .npmrc at the workspace root
public-hoist-pattern[]=some-native-pkg

You can hoist by exact name or by glob. If the package that gets bundled is actually a transitive dependency you do not control the name of, hoist that inner package, not the wrapper. The one that needs to resolve from root is whichever one Turbopack reports it cannot resolve.

Step 2: reinstall so the layout is rebuilt

rm -rf node_modules
pnpm install

Hoisting only takes effect when the store is relinked, so a plain second pnpm install without clearing node_modules may not move anything. Expect the package to now appear as a real entry at the top level.

Step 3: confirm it is resolvable from root

node -e "console.log(require.resolve('some-native-pkg'))"

If this prints a path under the top-level node_modules rather than throwing Cannot find module, Turbopack's resolution check will now pass and the package will stay external.

Workaround 2: build with webpack instead of Turbopack

If hoisting is awkward, or you have several packages hitting this, sidestep the resolution assumption entirely by building with webpack. webpack's externalization does not gate on resolving the package from the project root the same way, so serverExternalPackages behaves as documented.

# Turbopack build (exhibits the bug under pnpm)
next build --turbopack

# webpack build (serverExternalPackages resolves correctly)
next build

Check your package.json scripts, because many templates now put --turbopack on the build script by default. Removing it for the build while keeping it for next dev is a perfectly reasonable split: you get Turbopack's fast dev loop and a build that externalizes correctly. If your real problem is a different Turbopack resolution failure, the companion case is covered in Turbopack 'Cannot Find Module' With Pino and Dynamic requires.

How to verify the package is actually external

Do not assume it worked because the build passed. Prove the package stayed out of the bundle.

  1. Build, then search the server output for the package's code. If it is external, its source should not appear inlined in the compiled server chunks.
    grep -rl "some-native-pkg" .next/server | head
    A reference to the module name in a small runtime shim is fine; the package's actual implementation appearing there means it got bundled.
  2. Run the production server and exercise the code path that uses the package.
    next build && next start
    A native addon that previously threw about a missing .node file should now load, because the real package is being required from node_modules at runtime rather than from a mangled bundle.

What people get wrong

Assuming the config key is wrong or renamed. It is not. serverExternalPackages is the current stable key. Renaming it, or reaching for an old experimental spelling, will not help because the config was never the problem. The resolution check downstream of the config is.

Disabling pnpm's isolation wholesale with shamefully-hoist. Setting shamefully-hoist=true does make everything resolvable from root, and it does make the error go away, but it throws out the phantom-dependency protection that is the entire reason to use pnpm. You reintroduce the class of bug where code imports a package it never declared and it happens to work. Prefer public-hoist-pattern scoped to the specific package.

Adding the package to your own dependencies to force a link. If the bundled package is transitive, listing it directly in your package.json does create a top-level link and can fix resolution, but now you own a version constraint on a package you do not actually use directly, and it will drift from what your real dependency expects. Hoisting expresses the intent more honestly.

When it is still broken

  • Hoisted and still bundled. You probably hoisted the wrong name. Read the build error carefully; it names the package it could not resolve. Hoist that exact one, clear node_modules, reinstall.
  • Works locally, fails in CI. CI may cache node_modules or the pnpm store from before you changed .npmrc. Bust the cache so the install actually relinks with the new hoist pattern.
  • A monorepo makes it worse. In a workspace, the package might need hoisting at the workspace root .npmrc, not the app's. Resolution starts from where the build runs. If path aliases are also misbehaving, see why path aliases break across transpilePackages.
  • It regressed after a Next.js upgrade. Turbopack's resolution behaviour is still changing release to release. Check the Next.js issue tracker for your exact version before assuming your config broke, and pin a version that works until the fix lands.

The config is not the bug. The layout is. Make the package resolvable from your project root, or build with a bundler that does not demand it, and the phantom bundling stops.

Frequently asked questions

Why does serverExternalPackages work with npm but not pnpm?
npm and yarn flatten dependencies into a single top-level node_modules, so a package listed in serverExternalPackages can be resolved from your project root. pnpm keeps packages in an isolated .pnpm store and symlinks only your direct dependencies, so a package that is a transitive dependency cannot be resolved from the project directory, and Turbopack then declines to externalize it and bundles it instead.
How do I hoist a package so Turbopack can find it under pnpm?
Add the package to public-hoist-pattern in your .npmrc, then reinstall. This lifts the package into the top-level node_modules so it resolves from the project root the way Turbopack expects. For example, public-hoist-pattern[]=your-package makes that specific package resolvable without turning off pnpm's isolation everywhere.
Can I just switch the build to webpack to fix this?
Yes. serverExternalPackages resolves correctly under webpack because webpack's externalization does not make the same top-level resolution assumption. Running next build without the Turbopack flag uses webpack, which sidesteps the bug, at the cost of Turbopack's build speed.
Is this a bug in my package or in Next.js?
Neither exactly. It is an interaction between pnpm's strict symlinked layout and how Turbopack decides whether a package can be external. The same package and the same config work fine under npm or yarn, so it is package-manager-specific rather than a fault in the package itself.
#Next.js#Turbopack#pnpm#Module Resolution#Node.js
Keep reading

Related articles