esbuild has a surprisingly high opinion of itself when it comes to CSS.

Let’s see it in action. Imagine you’ve got a simple index.html and a couple of CSS files:

index.html:

<!DOCTYPE html>
<html>
<head>
  <title>esbuild CSS</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>Hello esbuild!</h1>
  <div class="box">This box should be blue.</div>
</body>
</html>

style.css:

.box {
  color: blue;
}

utils.css:

body {
  font-family: sans-serif;
}

Now, you want esbuild to bundle these, maybe even run them through PostCSS for some autoprefixing. Here’s how you’d typically do it with a simple esbuild command:

esbuild src/index.html --bundle --outfile=dist/bundle.js --loader:.css=css --servedir=dist --sourcemap --allow-overwrite

Wait, that’s for JS. For CSS, you’d do this:

esbuild src/style.css src/utils.css --bundle --outfile=dist/bundle.css --loader:.css=css --servedir=dist --sourcemap --allow-overwrite --format=esm

This command takes style.css and utils.css, bundles them into dist/bundle.css. The --loader:.css=css tells esbuild how to treat .css files. --servedir=dist is for the development server. --sourcemap gives you source maps. --allow-overwrite is useful for development. --format=esm isn’t strictly necessary for CSS but is good practice.

To get PostCSS in on this, you’d typically install esbuild-plugin-postcss:

npm install --save-dev esbuild-plugin-postcss postcss autoprefixer

And create a postcss.config.js:

// postcss.config.js
module.exports = {
  plugins: [
    require('autoprefixer')
  ]
}

Then, your esbuild command looks like this:

esbuild src/style.css src/utils.css --bundle --outfile=dist/bundle.css --loader:.css=css --servedir=dist --sourcemap --allow-overwrite --format=esm --plugins=./node_modules/esbuild-plugin-postcss/dist/index.js

The --plugins=./node_modules/esbuild-plugin-postcss/dist/index.js flag tells esbuild to load the PostCSS plugin. Now, when you run this, autoprefixer will kick in, and if you had any vendor prefixes needed, they’d be added.

The mental model here is that esbuild treats CSS as a first-class citizen, much like JavaScript. It can import CSS files, bundle them, and even transform them via plugins. The loader option is key for telling esbuild how to interpret different file types. When bundling, esbuild follows the import chain. If style.css were to @import "utils.css", esbuild would resolve that import and include utils.css in the final bundle.css.

The PostCSS plugin acts as a bridge. esbuild hands off the CSS content to the plugin, which in turn uses PostCSS to process it. PostCSS, with its own configuration and plugins (like autoprefixer), does the heavy lifting of transformations. esbuild then takes the processed CSS back and writes it to the output file.

What most people don’t realize is that esbuild’s CSS bundling capabilities extend to handling CSS Modules and even preprocessors like Sass or Less if you use the appropriate loaders or plugins. For example, to handle Sass, you’d install sass and use --loader:.sass=sass or --loader:.scss=scss. esbuild would then compile Sass to CSS before any PostCSS processing.

The next thing you’ll want to figure out is how to handle CSS-in-JS solutions or more advanced CSS features like CSS variables directly within your JavaScript bundles.

Want structured learning?

Take the full Esbuild course →