esbuild doesn’t inherently copy and process static assets like images or fonts; it’s designed primarily for JavaScript, CSS, and HTML.

Let’s see how this plays out in a real build. Imagine you have a project structure like this:

.
├── src/
│   ├── index.html
│   └── logo.png
└── package.json

And your esbuild.config.js looks like this:

const esbuild = require('esbuild');

esbuild.build({
  entryPoints: ['src/index.html'],
  bundle: true,
  outfile: 'dist/bundle.js',
  outdir: 'dist',
  platform: 'browser',
  format: 'esm',
  // No specific config for static assets here!
}).catch(() => process.exit(1));

When you run node esbuild.config.js, esbuild will process src/index.html, potentially inlining CSS or JS, and output dist/bundle.js. However, dist/logo.png will not be copied to the dist directory, nor will it be referenced correctly from index.html if you try to use an <img> tag pointing to src/logo.png. The build will complete, but your static assets will be missing in the output.

The core problem esbuild solves is fast, efficient compilation and bundling of your code (JS, TS, JSX, TSX, CSS, etc.) into optimized formats for the browser or Node.js. It achieves its speed by using Go for its core logic and avoiding slow JavaScript-based AST traversals. It transforms your source files into a minimal set of output files. However, its default behavior is to only consider files it knows how to transform into bundled code. Other files, like images, are outside its direct purview.

To handle static assets, you need to integrate them into the build process around esbuild. This typically involves a two-pronged approach:

  1. Referencing Assets: Making sure your HTML, CSS, or JS can correctly point to static assets in the output directory.
  2. Copying Assets: Ensuring those static assets actually get copied from your source to your output directory.

Here are the common ways to achieve this:

1. Using a separate copy command (simple projects):

For very basic needs, you can just add a cp or rsync command to your build script.

  • Diagnosis: Check your dist directory after running the esbuild build. Is logo.png there? Does your index.html correctly reference it (e.g., <img src="logo.png">)?

  • Fix: Modify your package.json scripts:

    "scripts": {
      "build": "node esbuild.config.js && cp src/logo.png dist/",
      "dev": "npm run build" // Or a watch command if you have one
    }
    
  • Why it works: This explicitly copies the file after esbuild has finished its work, ensuring it exists in the dist directory.

2. Using esbuild’s copy plugin (more integrated):

The esbuild-plugin-copy allows you to define file copying rules directly within your esbuild configuration.

  • Diagnosis: Same as above.

  • Fix: Install the plugin: npm install --save-dev esbuild-plugin-copy. Then update esbuild.config.js:

    const esbuild = require('esbuild');
    const { copy } = require('esbuild-plugin-copy');
    
    esbuild.build({
      entryPoints: ['src/index.html'],
      bundle: true,
      outfile: 'dist/bundle.js',
      outdir: 'dist',
      platform: 'browser',
      format: 'esm',
      plugins: [
        copy({
          assets: {
            from: ['src/logo.png'],
            to: ['dist/'],
          },
        }),
      ],
    }).catch(() => process.exit(1));
    
  • Why it works: The plugin intercepts esbuild’s build process and performs the file copy operation as part of the build, integrating it more seamlessly.

3. Using esbuild-loader for images in CSS/JS (for inlining or hashing):

If you want to import images directly into your JS or CSS, or have them automatically hashed for cache busting, you can use loaders.

  • Diagnosis: You might see errors like "Module not found" if you try to import logo from './logo.png'; in your JS, or your CSS url() references won’t work.

  • Fix: Install esbuild-loader: npm install --save-dev esbuild-loader. Then, configure esbuild to use it. For example, to import images and have them copied and hashed (often done by bundlers like Webpack/Vite, but esbuild itself doesn’t do this by default without plugins):

    // This is a conceptual example, as esbuild itself doesn't
    // directly handle inlining/hashing images via loaders without
    // additional plugins or a framework wrapper.
    // A common pattern is to use a framework like Vite that leverages esbuild
    // and provides this functionality.
    
    // If you were using a framework like Vite, the config might look like:
    // import { defineConfig } from 'vite';
    //
    // export default defineConfig({
    //   plugins: [react()], // Or other framework plugins
    //   build: {
    //     // esbuild is used by default for JS/TS, and Vite adds
    //     // specific asset handling on top.
    //     assetsDir: 'static', // Example: copies assets to dist/static/
    //   },
    // });
    
    • Why it works (conceptually): Loaders transform files before they are processed by esbuild. An image-loader might convert an image to a data URL (inlining) or copy it and return its public path, allowing your JS/CSS to reference it correctly. For automatic hashing, the bundler (or a plugin) would typically rename the file to logo.abcdef123.png and update all references.

4. Using a framework like Vite or Next.js:

These frameworks abstract away much of the complexity by using esbuild (or SWC) under the hood and providing robust asset handling out of the box.

  • Diagnosis: You’re managing esbuild directly and find yourself writing a lot of boilerplate for static assets.
  • Fix: Initialize a new project with Vite (npm create vite@latest my-app --template react-ts) or Next.js (npx create-next-app@latest). These tools handle static asset copying, referencing, and often optimization automatically. For example, in Vite, placing logo.png in your public/ directory makes it available at the root of your served app (e.g., /logo.png).
  • Why it works: Frameworks are opinionated and provide a complete solution. They integrate esbuild with other tools and plugins specifically designed for handling static assets, routing, and development servers.

The most common pitfall is forgetting that esbuild’s core strength is code transformation, not file system copying or asset management. You always need an explicit step or tool to handle static assets.

If you’ve correctly copied your assets and referenced them in your HTML/CSS, the next thing you might run into is ensuring those references are correct after a production build, especially if you’re using asset hashing for cache busting.

Want structured learning?

Take the full Esbuild course →