How to Fix 'react/jsx-runtime' Fully Specified ESM Import Errors in a Component Library Build
Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 10–20 mins
TL;DR
- What broke: Your component library's bundler (Rollup, Vite, webpack, or tsc) emitted or consumed a fully-specified ESM import —
react/jsx-runtime.js— that the host app's module resolver cannot find becausereactdoesn't ship a barejsx-runtime.jsfile at that path in all environments. - How to fix it: Align
tsconfig.jsonmoduleResolutiontobundlerornode16, set your bundler'sexternallist to treatreact/jsx-runtimeas an external (no extension rewriting), and pin@babel/plugin-transform-react-jsxtoruntime: 'automatic'without the.jssuffix. - Shortcut: Use our Client-Side Sandbox below to auto-refactor your
tsconfig.json,rollup.config.js, orvite.config.ts— zero data leaves your browser.
The Incident — What Does the Error Mean?
You hit one of these during build or at runtime in the host app:
Error: Cannot find module 'react/jsx-runtime.js'
Require stack:
- /node_modules/your-component-lib/dist/index.js
-- OR --
✘ [ERROR] Could not resolve "react/jsx-runtime.js"
node_modules/your-component-lib/src/Button.tsx:1:27:
1 │ import { jsx as _jsx } from 'react/jsx-runtime.js';
-- OR --
Module not found: Error: Package path ./jsx-runtime.js is not exported from package 'react'
Immediate consequence: Your library's dist output is unloadable. Every downstream consumer — CI pipelines, Storybook, Next.js apps, Vite apps — hard-crashes on import. There is no graceful degradation; the entire module graph fails.
The .js extension suffix is the killer. The react package exports react/jsx-runtime (no extension) via its package.json exports map. When TypeScript compiles with moduleResolution: node16 or nodenext, or when Rollup/esbuild rewrites imports to be ESM-spec-compliant, it appends .js to relative and bare specifiers. react has no exports["./jsx-runtime.js"] entry — so the resolver throws.
The Blast Radius
This isn't a dev-only nuisance. The blast radius is:
- Every consumer of your published npm package gets a broken install. If you've already published, all versions of the package on that tag are broken until you republish.
- SSR pipelines (Next.js App Router, Remix) fail at cold-start — no fallback, no partial render, hard 500.
- Monorepos using
workspace:*propagate the broken resolution to every app in the repo simultaneously. - Bundler caches (webpack persistent cache, Vite's
node_modules/.vite) may cache the broken resolution, causing ghost failures that survivenode_modulesdeletion until the cache dir is also wiped. - If your library is a design system used across multiple teams, you've just taken down every team's local dev server and their CI in a single bad publish.
The root causes cluster into three distinct failure modes:
| Root Cause | Trigger |
|---|---|
tsconfig moduleResolution: node16 / nodenext |
tsc rewrites react/jsx-runtime → react/jsx-runtime.js in .d.ts and .js emit |
Rollup output.format: 'es' + preserveModules: true |
Rollup's ESM output fully-specifies bare specifiers |
@babel/plugin-transform-react-jsx importSource misconfiguration |
Babel emits wrong import path for the JSX transform |
How to Fix It
Fix 1 — tsconfig.json moduleResolution (Most Common)
// tsconfig.json
{
"compilerOptions": {
- "module": "ESNext",
- "moduleResolution": "node16",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
"jsx": "react-jsx",
"declaration": true,
"declarationMap": true
}
}
Why: moduleResolution: bundler (TS 5.0+) does NOT append .js to bare specifiers like react/jsx-runtime. It delegates resolution to the bundler, which correctly uses the exports map. node16/nodenext enforces the ESM spec literally and appends the extension — correct for pure Node ESM, catastrophic for bundled libraries.
Fix 2 — Rollup Config external + paths (Enterprise Best Practice)
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
export default {
input: 'src/index.ts',
output: [
{
format: 'es',
dir: 'dist/esm',
- preserveModules: true,
+ preserveModules: true,
+ // Prevent Rollup from rewriting peer dep specifiers
+ paths: {
+ 'react/jsx-runtime': 'react/jsx-runtime',
+ 'react': 'react'
+ }
},
{
format: 'cjs',
dir: 'dist/cjs'
}
],
- external: ['react'],
+ external: ['react', 'react/jsx-runtime', 'react-dom'],
plugins: [
resolve(),
typescript({ tsconfig: './tsconfig.build.json' })
]
};
Why: Marking react/jsx-runtime as external explicitly tells Rollup: do not bundle this, do not rewrite this path. Without it, Rollup's ESM output mode may attempt to resolve and inline it, or rewrite the specifier with an extension during preserveModules output.
Fix 3 — Vite Library Mode (vite.config.ts)
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [react(), dts()],
build: {
lib: {
entry: 'src/index.ts',
formats: ['es', 'cjs']
},
rollupOptions: {
- external: ['react', 'react-dom'],
+ external: ['react', 'react-dom', 'react/jsx-runtime'],
}
}
});
Fix 4 — Babel Transform (If You're Not Using tsc Emit)
// babel.config.js
module.exports = {
presets: [
['@babel/preset-react', {
- runtime: 'automatic',
- importSource: 'react'
+ runtime: 'automatic'
+ // Do NOT set importSource unless you are using a custom JSX factory.
+ // Default resolves to react/jsx-runtime (no extension) correctly.
}]
]
};
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. Add a Post-Build Resolution Smoke Test
Add this to your package.json scripts and run it in CI before npm publish:
# Fails immediately if the broken specifier exists in dist output
grep -r 'jsx-runtime\.js' dist/ && echo "FAIL: fully-specified JSX runtime found" && exit 1 || echo "OK"
2. publint — The Right Tool for Library Authors
npx publint
publint statically analyzes your package.json exports map and dist output, flagging fully-specified ESM specifiers that won't resolve. Add it as a required CI step.
# .github/workflows/ci.yml
- name: Lint package exports
run: npx publint --strict
3. are-the-types-wrong (attw)
npx @arethetypeswrong/cli --pack .
This catches the moduleResolution mismatch at the type level before consumers hit it at runtime.
4. Enforce moduleResolution: bundler via ESLint or a Custom Checkov Policy
If your org uses multiple component libraries, enforce the tsconfig standard via a shared tsconfig.base.json and validate it in CI:
# Quick grep guard in CI
jq -e '.compilerOptions.moduleResolution == "bundler" or .compilerOptions.moduleResolution == "node"' tsconfig.build.json \
|| (echo "ERROR: moduleResolution must be bundler or node" && exit 1)
5. Pin Peer Dependency Declaration Correctly
// package.json of your component library
{
"peerDependencies": {
- "react": ">=16.0.0"
+ "react": ">=17.0.0"
},
+ "peerDependenciesMeta": {
+ "react": { "optional": false }
+ }
}
react/jsx-runtime was introduced in React 17. Declaring >=16 as a peer dep while using the automatic JSX transform is a latent misconfiguration that surfaces in mixed-version monorepos.