Skip to content

Fix: esbuild Not Working — Plugin Errors, CSS Not Processed, or Output Missing After Build

FixDevs ·

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 dependencies

Or 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 apply

Or TypeScript types aren’t checked during build:

npx esbuild src/index.ts --bundle
# Type errors silently ignored — no output about TS errors

Why 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 .css file 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 --noEmit separately for type checking.
  • JSX requires explicit configuration — by default esbuild assumes React.createElement for JSX. For Preact, Solid, or other frameworks, you must configure jsxFactory and jsxFragment.
  • 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.ts

CLI 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 --watch

Fix 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-plugin
import * 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 React

Fix 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 up

Still 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 expectedexternal: ['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 numbers

For related build tool issues, see Fix: Vite Build Failed and Fix: webpack Module Not Found.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles