Turbopack 'Cannot Find Module' With Pino and Dynamic requires
The build passes. The app boots. Then the first request that writes a log line dies, and the stack trace
points at a file inside node_modules/pino that is very obviously sitting right there on disk.
You check. The file exists. You node -e "require('pino')" and it works. You delete
node_modules, delete .next, reinstall, and it happens again. Nothing about your code
changed. What changed is that Next.js 16 made Turbopack the default bundler for next build, and
Turbopack resolves modules differently from webpack in exactly the case your logging library depends on.
This is not a logging bug. It is a class of bundler problem that will bite any package that computes a module path at runtime, and it is worth understanding rather than working around blindly.
the package is being bundled when it needs to stay external.
- Upgrade Next.js first.
pino,pino-pretty,pino-rollandthread-streamare on the built-in external packages list in current versions. - If you are pinned to an older 16.0.x, add them yourself with
serverExternalPackagesinnext.config.js. - If it still fails, build with webpack:
next build --webpack. - Long term, prefer libraries that do not resolve module paths at runtime, or keep the ones that do out of the bundle deliberately.
The exact error
Error: Cannot find module './transport-stream'
at Function.<anonymous> (node:internal/modules/cjs/loader)
at .../node_modules/pino/lib/worker.js
Variations you will see for the same underlying cause:
Cannot find module 'thread-stream'
Cannot find module './lib/worker.js'
Cannot find module '/ROOT/node_modules/.pnpm/.../pino/lib/worker.js'
The last one is the most informative: an absolute path with a build-time root prefix that does not exist at runtime. That is the shape of the whole problem.
Why Turbopack cannot resolve it and webpack could
A bundler builds a static graph. It reads your source, finds every import and every
require() with a literal string argument, follows them, and writes the result into output chunks.
Anything it cannot see at build time is not in the graph, and therefore not in the output.
Pino's transport system is deliberately dynamic. When you configure a transport, pino starts a worker thread
via thread-stream, and that worker resolves the transport target by path at runtime. The path is a
value, not a literal in the source. There is no require('./transport-stream') that a bundler can
match on the way in; there is a variable holding a string, computed after the process is already running.
Two things then go wrong at once:
- The sibling file was never bundled, because nothing in the graph referenced it statically.
- The worker's idea of "here" is wrong. Worker threads are started with a file path. If the
file that starts them has been rewritten into a bundle chunk, its relative siblings no longer exist relative to
the chunk.
./transport-streamresolves against a directory that has no such file.
Webpack did not magically understand this. Webpack was configured, by Next.js and by years of accumulated compatibility work, to leave a set of Node-specific packages alone. Turbopack arriving as the default reset some of that inherited configuration, and packages that were quietly being externalised started getting bundled.
The fix, step by step
Step 1: Check your Next.js version before changing anything
npx next --version
Next.js ships a built-in list of packages that are automatically opted out of server bundling, and
pino, pino-pretty, pino-roll and thread-stream are on it in
current releases. If you are on an early 16.0.x, an upgrade is the whole fix. Do that first, because every
workaround below is something you will later have to remember to remove.
Step 2: Externalise the package yourself
If you cannot upgrade, or the package is not on the built-in list, declare it:
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['pino', 'pino-pretty', 'thread-stream'],
};
module.exports = nextConfig;
This tells Next.js to leave those imports as native Node require calls in server code instead of
bundling them. The package then resolves from node_modules at runtime, with its own file layout
intact, and its internal dynamic requires work exactly as the author intended.
Two constraints worth knowing. This applies to server-side code only, so it does nothing for a package you
are importing into a client component. And the package must still be installed in the deployed environment,
which matters if you are using output: 'standalone' or a slimmed production install.
Step 3: Add the transitive dependency explicitly if the resolver cannot see it
With pnpm's default isolated node_modules layout, a package that is only a transitive dependency
is not directly resolvable from your project root. If thread-stream is only present because pino
pulled it in, adding it as a direct dependency removes that ambiguity:
pnpm add thread-stream
This is worth trying before reaching for the bundler flag, and it is harmless.
Step 4: Fall back to webpack for the build
If the above does not clear it, opt the build out of Turbopack:
{
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start"
}
}
You can keep Turbopack for next dev, where the speed actually matters day to day, and use webpack
only for the production build. That is a reasonable long-term posture for an app with heavy Node-side
dependencies, though the flag is an escape hatch rather than a destination.
Step 5: Reduce the surface instead
The most durable fix is often not a config key. Pino's transports exist to move formatting and I/O off the main thread. Inside a Next.js server that is frequently unnecessary. Logging as JSON straight to stdout and letting your platform collect it removes the worker thread, the dynamic require, and this entire class of problem:
import pino from 'pino';
// No transport, no worker thread, no runtime module resolution.
export const log = pino({
level: process.env.LOG_LEVEL ?? 'info',
});
Use pino-pretty as a separate process in development if you want readable output:
next dev | npx pino-pretty. Pretty-printing is a terminal concern, not an application concern.
Verification
A clean build is not proof. This failure happens at runtime, in the code path that starts the worker, so you have to exercise it.
rm -rf .next
npm run build
npm start
curl -s localhost:3000/api/health > /dev/null
# then check that a log line was actually written
Expect a JSON log line on stdout for that request and no stack trace. If you deploy in Docker, run the
production image locally and hit the same route, because a standalone output build has a different
node_modules layout from your development tree and that difference is precisely what breaks.
grep -R "serverExternalPackages" next.config.js
node -e "console.log(require.resolve('pino/lib/worker.js'))"
The second command should print a real path inside node_modules. If it throws in your deployed
container, the package is not installed there and no bundler setting will save you.
Which packages hit this
| Pattern in the library | Why bundlers struggle | What to do |
|---|---|---|
| Worker threads started from a file path | The worker entry is resolved at runtime relative to a file that no longer exists after bundling | Externalise the package |
require() with a computed string, such as a driver or dialect name | Nothing static to follow, so the target is never emitted | Externalise, or import the specific driver statically yourself |
Native .node addons | Binary, platform-specific, cannot be bundled meaningfully | Always external |
| Optional peer dependencies loaded in a try/catch | The bundler either fails on the missing module or pulls in something you never wanted | Externalise, or install the optional dep explicitly |
Before adopting Turbopack for production builds in an app with a heavy server-side dependency tree, this is the list to walk. Anything doing one of these four things is a candidate.
What people get wrong
- "Delete node_modules and reinstall." The file is present the whole time. Reinstalling
changes nothing because the resolution failure happens inside the bundle output, not in
node_modules. - "Add a webpack alias." Turbopack does not read your webpack config for this, and even under webpack an alias does not help a require whose argument is a runtime value.
- "Downgrade Next.js." Understandable in an incident, expensive afterwards. The version that worked was not correct, it was configured to avoid the problem. Externalising is the same fix without giving up the release.
- "Turbopack is broken, avoid it." Turbopack behaves the way a static bundler must behave. The libraries in question are doing something a static bundler cannot support, and the right answer is to keep them out of the bundle, not to keep a slower bundler forever.
When it is still broken
- Run the production image, not the dev server. Most reports of "fixed locally, broken on
deploy" are a
output: 'standalone'build whose file tracing did not copy the package. Check that the file exists inside the container. - Check for two copies of the package.
npm ls pinoorpnpm why pino. Two versions in the tree means one gets bundled and one does not, and which one wins is not obvious. - Move the initialisation into
instrumentation.ts. Logger setup that runs once at server start is easier for the bundler to reason about than a logger constructed inside a route module. - Search the issue tracker for your package name plus Turbopack. This class of bug is being worked through package by package, and the fix for yours may already exist on canary.
The general rule I now apply: if a Node package touches the filesystem, spawns a thread, or loads a driver by name, it does not belong in a bundle. Declare it external on day one and you never meet this error.
Frequently asked questions
- Why does Turbopack throw 'Cannot find module ./transport-stream' when webpack does not?
- Pino starts a worker thread whose transport target is resolved from a runtime value rather than a literal string, so no static bundler can see that file and emit it. Webpack did not solve this either, it was simply configured by Next.js to leave those Node-specific packages unbundled. When Turbopack became the default bundler, packages that were quietly being externalised started getting bundled and the runtime resolution broke.
- How do I stop Next.js bundling a Node package that needs runtime file resolution?
- Add it to serverExternalPackages in next.config.js, for example serverExternalPackages: ['pino', 'thread-stream']. That leaves the import as a native Node require so the package resolves from node_modules at runtime with its directory layout intact. It applies to server-side code only, and the package must still be installed in the deployed environment.
- Is 'next build --webpack' a safe long-term fix?
- It works and it is officially supported, but it is an escape hatch rather than a destination. A reasonable middle ground is Turbopack for next dev, where the speed matters daily, and webpack only for the production build. Revisit it after each Next.js upgrade, because the underlying compatibility gaps are being closed package by package.
- How do I avoid this class of bundler error entirely?
- Prefer libraries that do not compute module paths at runtime, and for the ones you must use, declare them external from day one. In pino's case, dropping the transport and logging plain JSON to stdout removes the worker thread and the dynamic require completely, with your platform's log collector doing the formatting. Anything that spawns a thread, loads a driver by name, or ships a native addon should be treated as external by default.