ERR_REQUIRE_ESM After a Next.js Upgrade: The CJS/ESM Interop Change Nobody Flagged
You bumped next from one 14.x patch to the next, ran your usual next build, and the pipeline that was green an hour ago is now red. Nothing in your own code changed. You did not touch shiki, or remark, or whichever ESM-only package the stack trace is pointing at. The build worked yesterday. It does not work today.
Your first instinct is to distrust the dependency. That instinct is wrong, and it will cost you an afternoon of bisecting node_modules. The dependency did not change. Next.js changed how it treats that dependency, and it did so in a routine minor bump without a migration note loud enough to notice.
This is not a broken package. It is a CommonJS/ESM interop boundary that Next.js tightened underneath you.
An ESM-only package is being pulled into a CommonJS server bundle and loaded with require(), which Node refuses. Pick one:
- Replace the synchronous
require()or static import in server code with a dynamicawait import('the-package'). - Or add the package to
serverExternalPackagesinnext.config.jsso Next stops bundling it and lets Node load it natively. - Or add it to
transpilePackagesso Next compiles it to CommonJS as part of your bundle.
A package must not appear in both serverExternalPackages and transpilePackages; Next throws at startup if it does. Do not downgrade Next permanently, and do not add "type": "module" to your own package.json to "fix" it.
The exact error: require() of ES Module not supported
The message that lands in your build log or your server logs looks like this:
Error [ERR_REQUIRE_ESM]: require() of ES Module
/node_modules/.pnpm/shiki@1.2.1/node_modules/shiki/dist/index.mjs
not supported.
Instead change the require of index.mjs to a dynamic import()
which is available in all CommonJS modules.
The package name will differ. The shape will not. Notice the two facts the message hands you for free: the file is a .mjs (so the package is shipping ES Modules), and something in the CommonJS world called require() on it. That "something" is almost always the server bundle Next.js built.
Why a working build breaks on a minor bump
There are two moving parts, and the confusion comes from blaming the wrong one.
The first part is the package. An ESM-only package publishes only ES Module code. Its package.json has "type": "module", or an exports map that points every entry at a .mjs file, and it deliberately ships no CommonJS build. shiki, the unified and remark family, and a growing list of others made this jump. That is a decision the maintainers made months ago; it is not what broke today.
The second part is Next.js. When it builds your server-side code, it produces a bundle. Depending on the version and how the package is referenced, Next either inlines the package into that bundle or leaves it as an external require() call resolved at runtime. Between the 14.1 line and the 14.2 line, the internal handling of these externals (the machinery behind experimental.esmExternals) was tightened. Packages that previously got a lenient interop path now hit a strict one, and the strict path ends in a plain CommonJS require() against a .mjs file. Node has exactly one response to that: ERR_REQUIRE_ESM.
So nothing in your dependency tree changed. The build tool changed the rules for a category of dependency you happened to be using. That is why the usual "delete node_modules and the lockfile, reinstall" ritual does nothing: the inputs are identical, the compiler is not. It is a close cousin of the ERR_REQUIRE_ESM that shows up on Vercel but not locally, except this one is triggered purely by the version bump.
Find the culprit in one step, not by bisecting
People burn hours removing dependencies one at a time to find "the ESM one." You do not need to. The error already told you.
Step 1: Read the .mjs path in the stack trace
The file in the ERR_REQUIRE_ESM message is the package Next tried to require. In the example above that is shiki. That is your target. If you want to confirm a package is ESM-only from the command line:
node -e "require('shiki')"
# Throws: Error [ERR_REQUIRE_ESM]: require() of ES Module ...
# Or just look at what it ships:
cat node_modules/shiki/package.json | grep -E '"type"|"main"|"module"|"exports"'
A "type": "module" with no CommonJS main is the signature of an ESM-only package.
Step 2: Fix it at the call site with a dynamic import
If you control the code that pulls the package in, this is the cleanest fix. A dynamic import() is legal inside CommonJS and returns a promise, so it crosses the boundary asynchronously:
// Before (fails once Next requires the bundle as CJS)
import { createHighlighter } from 'shiki'
// After
export async function highlight(code: string, lang: string) {
const { createHighlighter } = await import('shiki')
const highlighter = await createHighlighter({ themes: ['github-dark'], langs: [lang] })
return highlighter.codeToHtml(code, { lang, theme: 'github-dark' })
}
Put this in a Server Component, a Route Handler, or a server action, anywhere you already have an async function. Expect next build to complete cleanly afterwards.
Step 3: Or tell Next.js how to treat the package
When the require() comes from inside another dependency you do not control, you cannot rewrite the call site. Reach for next.config.js instead. There are two levers and they do opposite things:
// next.config.js
module.exports = {
// Opt the package OUT of bundling. Node loads its native ESM at runtime.
serverExternalPackages: ['shiki'],
// OR: pull the package INTO your bundle and transpile it to CommonJS.
// transpilePackages: ['shiki'],
}
On older Next versions the first key lived under experimental.serverComponentsExternalPackages; it graduated to the top-level serverExternalPackages. Confirm the exact name against the serverExternalPackages reference for your version before you paste.
| Option | What it does | Use when |
|---|---|---|
await import() | Loads the ESM package lazily at runtime | You own the call site and can be async |
serverExternalPackages | Excludes it from the bundle; Node requires it natively as ESM | A transitive dep pulls it in, or it uses Node-native features |
transpilePackages | Bundles and transpiles it to CommonJS with your app | The package ships untranspiled source you want compiled with your app |
Do not list the same package in both serverExternalPackages and transpilePackages. Next.js treats that as a contradiction and refuses to start.
Verify the fix against a real build
The trap here is that next dev and next build compile differently. A dynamic import may look fine in dev and still fail the production build, or the reverse. Always prove the fix with the command that failed:
rm -rf .next
next build && next start
# Watch for a clean "Compiled successfully" with no ERR_REQUIRE_ESM,
# then hit the route that uses the package and confirm it renders.
If the page that uses the highlighter renders in the production server, you are done. This is the same discipline I argue for in shipping to your own VPS with GitHub Actions: the build that matters is the one your pipeline runs, not the one on your laptop.
What people get wrong
Pinning Next.js to the old minor forever. It buys you a day and mortgages the next one. You will eventually take a security patch that only ships on the new line, and the problem returns with less time to fix it. Pin deliberately, with a note, while you apply the real fix, not as the destination.
Adding "type": "module" to your own package.json. This does not make the error go away; it converts your entire project to ESM and cascades new failures through every config file and CommonJS helper you own. It is a much larger change wearing the disguise of a one-liner.
Blindly setting experimental.esmExternals: false because a forum thread said so. That flag changes interop behaviour for every external, not just the one that broke, and it is experimental for a reason. If you reach for it, you are treating a symptom you have not diagnosed.
Blaming the package maintainer. Shipping ESM-only is a legitimate, forward-looking choice. The break is at the interop seam in your build, and that is where you fix it.
When it is still broken
If the error survives all three fixes, work through this:
- Check your Node version. Recent Node releases can
require()an ES Module in more cases than older ones, so behaviour differs across your local, CI, and production runtimes. A build that passes on one Node major and fails on another in CI is an environment mismatch, not a code bug. Align the versions. - It might be transitive. If the ESM-only package is required by another dependency rather than by you, the dynamic-import fix is not available. Externalize the top-level dependency (the one you actually installed) via
serverExternalPackages. - Check whether it is in Edge, not Node. Middleware and Edge routes run a different runtime with its own module rules; the same import can fail there for unrelated reasons. If the failing code path is middleware, that is a separate diagnosis, closely related to the Turbopack "cannot find module" dynamic-require problem.
- Isolate dev versus build. If only the production build fails, you are looking at how Next externalizes for production specifically, and the config-level fix is the right layer, not the call site.
The lesson worth keeping: a Next.js minor bump can change the semantics of your dependency graph without touching a single line of your code or a single version in your lockfile. Treat minor bumps like the potentially breaking events they are. Pin them, and test the full production build before you merge, not just next dev. That one habit turns this from a 2am incident into a caught-in-review annoyance.
Frequently asked questions
- Why did ERR_REQUIRE_ESM appear after upgrading Next.js when I did not change any dependency?
- Because the break is in Next.js, not your dependency. Between the 14.1 and 14.2 lines Next tightened how it handles ESM externals, so a package that previously got a lenient interop path is now loaded with a strict CommonJS require() against its .mjs file, which Node rejects. Your lockfile is unchanged; the compiler's rules changed.
- What is the difference between serverExternalPackages and transpilePackages?
- serverExternalPackages excludes the package from your bundle so Node loads its native ESM at runtime. transpilePackages does the opposite: it pulls the package into your bundle and transpiles it to CommonJS alongside your app. Use externalization for transitive or Node-native packages, and transpilePackages for packages shipping untranspiled source. A package must never appear in both lists.
- Can I just downgrade Next.js to make ERR_REQUIRE_ESM go away?
- It works but only as a stopgap. Pinning to the old minor means you will eventually be forced onto the new line by a security patch, and the problem returns with less time to solve it. Downgrade deliberately with a note while you apply a real fix (dynamic import, serverExternalPackages, or transpilePackages), not as your final answer.
- How do I find which dependency is ESM-only?
- You do not need to bisect. The ERR_REQUIRE_ESM message names the .mjs file it tried to require, and that is the package. To confirm, run node -e "require('the-package')" and watch it throw, or check its package.json for "type": "module" with no CommonJS main entry.