Fix: esbuild Not Working — Plugin Errors, CSS Not Processed, or Output Missing After Build
Quick Answer
How to fix esbuild issues — entry points, plugin API, JSX configuration, CSS modules, watch mode, metafile analysis, external packages, and common migration problems from webpack.
The Problem
esbuild runs but the output is empty or the bundle is missing files:
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js
# Build succeeded but dist/bundle.js doesn't contain dependenciesOr a plugin throws an error that’s hard to interpret:
✘ [ERROR] Cannot find module 'some-package'
node_modules/some-package/index.js:1:0:
1 │ import { something } from './internal';Or CSS isn’t being processed:
npx esbuild src/app.ts --bundle --outfile=dist/bundle.js
# CSS imports are extracted to a separate file, but styles don't applyOr TypeScript types aren’t checked during build:
npx esbuild src/index.ts --bundle
# Type errors silently ignored — no output about TS errorsWhy This Happens
esbuild is a bundler, not a full build system:
- esbuild doesn’t install modules — it bundles what’s already installed. If a package is missing from
node_modules, esbuild fails. The error message points to the file that imports the missing module. - CSS is always extracted into a separate file — when CSS is imported in JS, esbuild writes it to a
.cssfile alongside the JS output. You must link that CSS file in your HTML. - esbuild doesn’t type-check TypeScript — it strips type annotations but never runs the TypeScript compiler. Use
tsc --noEmitseparately for type checking. - JSX requires explicit configuration — by default esbuild assumes
React.createElementfor JSX. For Preact, Solid, or other frameworks, you must configurejsxFactoryandjsxFragment. - esbuild plugins use a different API than webpack — the esbuild plugin API is simpler but structurally different. webpack plugin patterns don’t translate directly.
Fix 1: Configure the Build Script Correctly
Use the JavaScript API for complex builds rather than the CLI:
// build.js (or build.ts with tsx/ts-node)
import * as esbuild from 'esbuild';
// Basic bundle
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
platform: 'browser', // 'browser' | 'node' | 'neutral'
format: 'esm', // 'iife' | 'cjs' | 'esm'
target: 'es2020', // Set browser/node target
sourcemap: true,
minify: process.env.NODE_ENV === 'production',
});
// Multiple entry points
await esbuild.build({
entryPoints: ['src/app.ts', 'src/admin.ts'],
bundle: true,
outdir: 'dist', // outdir (not outfile) for multiple outputs
splitting: true, // Code splitting between entry points
format: 'esm', // Required for splitting
chunkNames: 'chunks/[name]-[hash]',
});
// Node.js application
await esbuild.build({
entryPoints: ['src/server.ts'],
bundle: true,
outfile: 'dist/server.js',
platform: 'node',
format: 'cjs',
target: 'node18',
external: ['express', 'pg', 'bcrypt'], // Don't bundle native modules
packages: 'external', // Externalize ALL packages from node_modules
});Run the build:
node build.js
# Or with TypeScript:
npx tsx build.tsCLI equivalent for quick builds:
# Browser bundle
npx esbuild src/index.ts \
--bundle \
--outfile=dist/bundle.js \
--format=esm \
--target=es2020 \
--sourcemap
# Node.js bundle
npx esbuild src/server.ts \
--bundle \
--outfile=dist/server.js \
--platform=node \
--packages=external
# Watch mode
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js --watchFix 2: Handle CSS and Assets
esbuild processes CSS imports from JavaScript, but the output is always a separate file:
// src/app.ts
import './styles/global.css';
import './components/Button.css';
// esbuild extracts all CSS to dist/app.css<!-- index.html — you must link the CSS yourself -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="dist/app.css">
</head>
<body>
<script type="module" src="dist/app.js"></script>
</body>
</html>Handle CSS Modules with a plugin:
esbuild doesn’t support CSS Modules natively. Use esbuild-css-modules-plugin:
npm install --save-dev esbuild-css-modules-pluginimport * as esbuild from 'esbuild';
import cssModulesPlugin from 'esbuild-css-modules-plugin';
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
plugins: [
cssModulesPlugin({
// Options
force: true, // Process all .css as modules
emitDeclarationFile: true, // Generate .d.ts for TypeScript
}),
],
});Copy static assets:
import { copy } from 'esbuild-plugin-copy';
await esbuild.build({
plugins: [
copy({
assets: [
{ from: './public/**/*', to: './dist' },
{ from: './src/assets/**/*', to: './dist/assets' },
],
}),
],
});Inline small assets as data URLs:
await esbuild.build({
loader: {
'.svg': 'dataurl', // Inline SVGs as data URLs
'.png': 'file', // Copy to outdir with hash in filename
'.woff2': 'file',
'.ttf': 'file',
},
});Fix 3: Configure JSX for Different Frameworks
// React (default — no configuration needed for React 17+)
await esbuild.build({
jsx: 'automatic', // React 17+ automatic runtime
// No jsxFactory needed with automatic runtime
});
// React classic (pre-17 or custom babel config)
await esbuild.build({
jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment',
inject: ['./react-shim.js'], // Auto-import React
});
// Preact
await esbuild.build({
jsxFactory: 'h',
jsxFragment: 'Fragment',
inject: ['./preact-shim.js'],
});
// preact-shim.js: export { h, Fragment } from 'preact';
// Solid.js
await esbuild.build({
jsx: 'transform',
jsxFactory: 'createComponent',
// Use esbuild-plugin-solid instead:
plugins: [solidPlugin()],
});
// Vue (use @vitejs/plugin-vue with Vite, not esbuild directly)
// Svelte (use esbuild-svelte plugin)Per-file JSX pragma (override in specific files):
// src/preact-component.tsx
/** @jsxRuntime classic */
/** @jsx h */
/** @jsxFrag Fragment */
import { h, Fragment } from 'preact';
// This file uses Preact JSX even if the build default is ReactFix 4: Write esbuild Plugins
The esbuild plugin API intercepts file resolution and loading:
// Example: resolve path aliases
const aliasPlugin = {
name: 'alias',
setup(build) {
// Intercept imports starting with '@/'
build.onResolve({ filter: /^@\// }, (args) => {
const path = args.path.replace('@/', './src/');
return { path: require('path').resolve(path) };
});
},
};
// Example: inline environment variables
const envPlugin = {
name: 'env',
setup(build) {
// Virtual module — imported as 'env'
build.onResolve({ filter: /^env$/ }, () => ({
path: 'env',
namespace: 'env-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify({
NODE_ENV: process.env.NODE_ENV ?? 'development',
API_URL: process.env.API_URL ?? '',
}),
loader: 'json',
}));
},
};
// Example: YAML loader
const yamlPlugin = {
name: 'yaml',
setup(build) {
build.onLoad({ filter: /\.ya?ml$/ }, async (args) => {
const { readFile } = await import('fs/promises');
const { parse } = await import('yaml');
const source = await readFile(args.path, 'utf8');
return {
contents: JSON.stringify(parse(source)),
loader: 'json',
};
});
},
};
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
plugins: [aliasPlugin, envPlugin, yamlPlugin],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'development'),
},
});Plugin error handling:
const myPlugin = {
name: 'my-plugin',
setup(build) {
build.onLoad({ filter: /\.special$/ }, async (args) => {
try {
const result = await processFile(args.path);
return { contents: result, loader: 'js' };
} catch (error) {
return {
errors: [{
text: `Failed to process ${args.path}: ${error.message}`,
location: { file: args.path },
}],
};
}
});
},
};Fix 5: Use the Metafile for Bundle Analysis
The metafile tells you exactly what’s in your bundle:
const result = await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
metafile: true, // Enable metafile
});
// Analyze with esbuild's built-in analyzer
const analysis = await esbuild.analyzeMetafile(result.metafile, {
verbose: true,
});
console.log(analysis);
// Write metafile for external tools
import { writeFileSync } from 'fs';
writeFileSync('meta.json', JSON.stringify(result.metafile));
// Upload meta.json to https://esbuild.github.io/analyze/
// Programmatic inspection
const outputs = result.metafile.outputs;
for (const [file, info] of Object.entries(outputs)) {
console.log(`${file}: ${(info.bytes / 1024).toFixed(1)} KB`);
// Show top 5 largest inputs
const inputs = Object.entries(info.inputs)
.sort(([, a], [, b]) => b.bytesInOutput - a.bytesInOutput)
.slice(0, 5);
for (const [path, data] of inputs) {
console.log(` ${path}: ${data.bytesInOutput} bytes`);
}
}Fix 6: Rebuild and Watch Mode
// Watch mode — rebuilds on file changes
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
});
// Start watching
await ctx.watch();
console.log('Watching for changes...');
// Stop watching
process.on('SIGINT', async () => {
await ctx.dispose();
process.exit(0);
});
// Dev server with live reload
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
});
// Built-in dev server (serves outdir)
await ctx.serve({
servedir: 'dist',
port: 3000,
onRequest: ({ remoteAddress, method, path, status, timeInMS }) => {
console.log(`${remoteAddress} - "${method} ${path}" [${status}] in ${timeInMS}ms`);
},
});
// Or use the rebuild API for programmatic incremental builds
const ctx = await esbuild.context(buildOptions);
await ctx.rebuild(); // Initial build
// Later:
await ctx.rebuild(); // Incremental rebuild — faster
await ctx.dispose(); // Clean upStill Not Working?
TypeScript path aliases aren’t resolved — esbuild reads tsconfig.json for basic TypeScript settings, but it doesn’t implement TypeScript’s paths aliasing by default. Use esbuild-plugin-tsconfig-paths or configure path aliases manually in a plugin. Alternatively, convert path aliases to resolve.alias in your esbuild config:
await esbuild.build({
plugins: [{
name: 'tsconfig-paths',
setup(build) {
build.onResolve({ filter: /^@components\// }, (args) => ({
path: args.path.replace('@components/', './src/components/'),
}));
},
}],
});Tree shaking not removing dead code — esbuild performs tree shaking automatically for ESM bundles. It doesn’t tree-shake CommonJS. If your dependencies use CJS, dead code won’t be eliminated. Check if the package has an exports field with ESM ("module" or "import" condition). For packages that don’t have ESM builds, consider esbuild-plugin-commonjs or switch to ESM-only alternatives.
external not working as expected — external: ['lodash'] marks lodash as external, meaning it won’t be bundled but the import remains in the output. The consuming environment must provide lodash. For browser builds, this means the package must be available as a global or via an import map. For Node.js, external: ['express'] means require('express') stays in the output and Node resolves it from node_modules at runtime.
Source maps not working in Node.js — esbuild generates source maps, but Node.js doesn’t use them by default. Install source-map-support and require it at the top of your entry file, or use --enable-source-maps flag in Node 18+:
node --enable-source-maps dist/server.js
# Stack traces now show original TypeScript line numbersFor related build tool issues, see Fix: Vite Build Failed and Fix: webpack Module Not Found.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: ky Not Working — Requests Failing, Hooks Not Firing, or Retry Not Working
How to fix ky HTTP client issues — instance creation, hooks (beforeRequest, afterResponse), retry configuration, timeout handling, JSON parsing, error handling, and migration from fetch or axios.