Handling CSS Modules in an esbuild Build

esbuild bundles CSS natively, and since 0.18 it supports CSS Modules: a .module.css file’s class names are hashed to locally-scoped identifiers and exported as a JS object you import. This guide wires that up, controls the generated names, and verifies the scoping. It applies the loader model from custom loaders and asset handling; the parent overview is esbuild & Turbopack Workflows.

The problem CSS Modules solve is the global namespace that plain CSS forces on you. Every selector in every stylesheet lands in one flat space, so a .button written for a card component and a .button written for a toolbar are the same rule as far as the browser is concerned — whichever loads last wins, and the loser is styled by an unrelated component’s declarations. On a small page you catch this by eye; in an application with a few hundred components and stylesheets bundled in an order that shifts whenever the dependency graph changes, you catch it in production as a visual regression with no stack trace. CSS Modules make the collision structurally impossible by rewriting each class to a name unique to its source file before the CSS is ever concatenated.

Historically that rewrite lived in a separate toolchain — css-loader under webpack, or postcss-modules in a PostCSS pipeline — bolted onto the JS bundler through a plugin. esbuild folds it into the bundler itself: the same pass that parses, minifies, and concatenates your CSS also does the scoping, so there is no plugin to install, no PostCSS config to keep in sync, and no second parse of the stylesheet. The cost of that integration is that the behavior is fixed by esbuild’s implementation rather than configurable the way a PostCSS plugin chain is — you get esbuild’s hashing scheme and esbuild’s :local/:global semantics, and the knobs are limited to what esbuild chooses to expose. For most builds that trade is worth it; the rest of this guide is about the cases where the fixed behavior surprises you.

Where this sits in the pipeline: CSS Modules resolution happens during bundling, after module resolution has located the .module.css file and before the CSS is written to the output chunk. The JS side of the import (styles.button) and the CSS side (the .button_a1b2 rule) are produced in the same step from the same parsed representation, which is why the two never drift out of sync — there is no separate manifest file that could go stale.

A module.css exports a class map of scoped names esbuild's local-css loader hashes each class in a module.css file to a unique scoped name, emits the CSS with those names, and exports a JS object mapping the original class name to the scoped one for the importing module to use. Scope the class, export the map .button in .module.csssource class local-css loader.button → button_a1b2 import stylesstyles.button the JS map is how the component references the hashed class
Figure: the class is hashed once, emitted in the CSS, and exposed to JS through the exported map.

Prerequisites & reproducible setup

# esbuild 0.25.x, Node 18+
mkdir cssmod-demo && cd cssmod-demo && npm init -y
npm install --save-dev esbuild@0.25.0
/* styles.module.css */
.button { color: white; background: rebeccapurple; }
// app.ts
import styles from './styles.module.css';
document.body.className = styles.button;   // the scoped name

esbuild routes files matching *.module.css through its built-in local-css loader automatically; a plain .css file uses the global-css loader with no scoping. The distinction is the filename.

The suffix match is on the resolved on-disk path, not the import specifier, so import './styles.module.css' and a re-export chain that ends at the same file both get the local loader — what matters is where resolution lands. This also means you cannot rename a file to .css in the import and keep scoping, and you cannot force scoping on a .css file by aliasing it to a .module.css name; the loader picks its behavior from the real filename esbuild opens. If you have a stylesheet you want scoped but cannot rename (a vendored file, for instance), the clean options are to copy it to a .module.css name in a build step or to assign the local-css loader explicitly for that path, discussed below.

Keep the styles.module.css and app.ts from the setup above; every command and config in the rest of the guide assumes that working directory. The app.ts reads styles.button off the imported map, so once the build runs you can read the emitted dist/app.css and dist/app.js and see the same hashed token in both — that agreement is the whole point of the exercise.

The filename decides local versus global CSS esbuild treats a file named with the module.css suffix as CSS Modules using the local-css loader that scopes class names, while a plain css file uses the global-css loader with no scoping, so the naming convention is the switch. styles.module.csslocal-css — scoped styles.cssglobal-css — unscoped the .module suffix is the whole opt-in
Figure: the .module.css suffix is the entire opt-in — no config flag needed.

How it works under the hood

The local-css loader parses the stylesheet into esbuild’s CSS AST, then walks the selector list. For every simple class selector that is not wrapped in :global(...), it generates a scoped name and records a mapping from the original name to the scoped one. Two things then consume that mapping. The CSS printer rewrites the selectors in place, so the .button rule is emitted as .button_a1b2 in the output chunk. The JS side synthesises a module whose default export is an object literal built from the same mapping, so import styles from './styles.module.css' resolves to { button: "button_a1b2" }. Because both outputs are generated from one mapping in one pass, there is no window in which the CSS and the JS can disagree.

The scoped name is derived from the class name plus a hash of the file’s path (and content, depending on version), which is what makes it stable across builds and unique across files. Two different files that both declare .button hash to different suffixes because the file identity feeds the hash; the same file rebuilt without changes hashes to the same suffix, which is why a warm rebuild does not needlessly bust downstream content hashes. The hash is deterministic, not random, so the output is reproducible in CI — the same inputs always produce the same class names, and a diff in your emitted CSS means a real change upstream, not loader nondeterminism.

Scoping applies to class selectors. Element selectors (div, a), ID selectors, attribute selectors, and keyframe/animation names follow their own rules: element and attribute selectors are never scoped because they do not name a class, and animation names declared with @keyframes inside a module are scoped so a component’s animation cannot clash with another’s. This matters when a rule mixes a scoped class with a bare element selector — .card p scopes .card and leaves p global, which is the intended behavior but occasionally surprises people who expected the descendant to be namespaced too.

Diagnosis workflow

  1. Confirm the import returns a map. Log styles — it should be an object mapping original class names to hashed ones, not undefined.
  2. Check the loader assignment. If a .module.css import returns a string or fails, the file may be caught by an explicit loader override; let esbuild’s default local-css apply.
  3. Decide the naming pattern. The default hashed name is fine for production; for readable dev output, set a local-css name pattern that keeps the original class.

Each of those steps isolates a different failure mode, so run them in order rather than guessing. The first check separates “the loader ran but I misread the result” from “the loader never ran.” A correct import is a plain object; if console.log(styles) prints undefined, the import itself failed to resolve, which is a module-resolution or filename problem, not a scoping one. If it prints a string of CSS text, the file went through a text-emitting loader instead of local-css — most often because someone added a loader: { '.css': 'text' } or 'css' entry that also captures .module.css, since the suffix is still a .css file to the loader map.

The second check confirms the loader assignment. esbuild’s built-in loader table already maps .module.css to local-css; the only way to lose that is to override it. Print the resolved config or temporarily remove any loader block and rebuild — if the import becomes an object again, the override was the culprit. The fix is never to re-add local-css by hand (that works, but signals you deleted the default for no reason); it is to scope your explicit loader entries to the extensions they actually need and leave CSS alone.

The third check is about ergonomics, not correctness. The default hashed name is opaque by design, which is what you want in production but painful in the dev inspector, where button_a1b2c3 tells you nothing about which component emitted it. Confirm which behavior you have by grepping the output: a name like .button_ with a hash is the default; a name that preserves the readable button prefix means a name pattern is in effect. Decide deliberately which one each build target should use rather than discovering it in the browser.

The import should be an object, not a string A correctly loaded CSS Module import is a JavaScript object mapping original class names to scoped ones, so if the import is undefined or a raw string the file is being handled by the wrong loader rather than local-css. import = object{ button: 'button_a1b2' }local-css working import = string / undefinedwrong loaderoverride removed local-css
Figure: an object import means local-css ran; a string or undefined means a loader override overrode it.

Annotated solution config

The correct configuration is mostly about what you do not write. Because .module.css is already routed to local-css, a working setup needs no CSS entry in the loader map at all — the block below only exists to map other asset types and to document the trap. The single rule to internalise is that any key in loader that matches CSS extensions replaces the built-in behavior for those files, and there is no “extend the defaults” mode; a loader entry is a full override for the extensions it names.

// build.mjs — esbuild 0.25.x, Node 18+, CSS Modules.
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  outdir: 'dist',
  // .module.css → local-css automatically; do NOT add a loader override
  // that would replace it. This block only tweaks the generated names.
  loader: {
    // Leave CSS to the built-in loaders; map other assets here as needed.
    '.png': 'file',
  },
  // Optional: readable class names in development.
  // localCss name pattern uses [local] (original) and [hash].
  // Production defaults to a short hash for privacy and size.
});
// For readable dev names, esbuild 0.25 honors this via the CLI/JS option.
// (Names are hashed by default; keep the default in production.)
{
  "//": "no extra config needed for correctness — the suffix drives it"
}
Do not override the CSS loader The correct configuration bundles with the module.css suffix and leaves the CSS loaders to esbuild's built-in local-css and global-css; adding an explicit loader entry for css would override that and break scoping. leave CSS to built-inslocal-css runsscoping works loader: {'.css': ...}overrides local-cssbreaks scoping
Figure: the one thing not to do is add a CSS loader override — it disables the module scoping.

If you do need one build target with readable names and another with hashed names — the common split between a debug build and a release build — drive it from an environment flag rather than maintaining two config files. The pattern below keeps a single build.mjs and lets the caller choose. In esbuild the readable-name behavior is exposed through the CSS local-css naming; where a given esbuild release does not expose a name template, the equivalent readable-output strategy is to skip minification and identifier mangling in the dev build so the emitted selector still carries its original prefix, and reserve the opaque hashed form for the minified release build.

// build.mjs — esbuild 0.25.x, Node 18+. One config, two targets.
import * as esbuild from 'esbuild';

const dev = process.env.NODE_ENV !== 'production';

await esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  outdir: 'dist',
  // Dev: keep output legible so scoped selectors stay greppable in the
  // inspector. Release: minify, which is where the hashed form pays off.
  minify: !dev,
  // Never add a '.css' or '.module.css' key here — that removes local-css.
  loader: { '.png': 'file', '.woff2': 'file' },
});

Run it either way with NODE_ENV=production node build.mjs for the release output or a bare node build.mjs for the legible dev output. The scoping itself is identical across both — only the surrounding minification changes — so a class that collides in one build collides in the other; the flag never affects correctness, only readability.

Verification

# esbuild 0.25.x — build and confirm scoped names in the emitted CSS + JS.
node build.mjs
grep -o 'button_[A-Za-z0-9]*' dist/app.css | head -1   # a hashed class name
grep -c 'button_' dist/app.js                           # JS references the same name

The emitted CSS contains a hashed class like .button_a1b2c3, and the JS references that exact name via the imported map — so two components each with a .button in their own .module.css never collide. Removing the .module from the filename makes the class global and the hashing disappears.

Treat the two grep lines as an assertion, not a spot check. The first pulls the scoped selector out of the CSS; the second counts references to the same prefix in the JS. If the CSS line prints a name and the JS count is zero, the stylesheet was scoped but the class map was tree-shaken away because nothing in app.ts read a property off styles — esbuild drops the unused import, and the CSS ships with a hashed class no code applies. That is not a scoping failure, it is dead code, and it usually means the component references the class by a hard-coded string instead of through the map. The confirmation that everything is wired correctly is a non-zero JS count whose captured name is byte-for-byte the name the CSS grep printed.

For a stricter gate, capture the name from the CSS and assert its presence in the JS instead of eyeballing both. The one-liner below fails loudly when the two disagree, which is exactly the shape you want in a pre-commit hook or CI step:

# esbuild 0.25.x — fail the build if the scoped name is missing from the JS.
node build.mjs
name=$(grep -o 'button_[A-Za-z0-9]*' dist/app.css | head -1)
grep -q "$name" dist/app.js && echo "ok: $name in both" || { echo "drift: $name missing from JS"; exit 1; }
CSS and JS reference the same hashed name Verification confirms the emitted CSS contains a hashed class name and the JS references the same hashed name through the imported map, which is what guarantees two components' identically-named classes do not collide. .button_a1b2 in CSSemitted button_a1b2 in JSvia the map = matching names across CSS and JS is the scoping proof
Figure: the same hashed name in both outputs is proof the scoping and the map agree.

Gotchas & edge cases

Four CSS-Modules edge cases CSS Modules need esbuild 0.18 or later; :global escapes scoping deliberately; composes references another module's class and must resolve; and a plain css file gets no scoping so an accidental class collision is silent. esbuild < 0.18→ no CSS Modules, upgrade :global escapes scope→ use it deliberately composes must resolve→ import the other module plain .css collides silently→ use .module.css to scope
Figure: a plain .css class silently collides across files — the .module suffix is what prevents it.
  • Requires esbuild 0.18+. Earlier versions have no CSS Modules support; upgrade or use a plugin.
  • :global opts out. A :global(.name) selector is intentionally unscoped; use it only where you mean a global class.
  • composes must resolve. Composing from another module requires that module to be importable; a bad path breaks the build.
  • Plain CSS collides silently. Two files each defining .button in non-module CSS overwrite each other with no warning; scope with .module.css.

The version floor bites hardest on inherited pipelines. On esbuild before 0.18 a .module.css import does not error — it goes through the ordinary CSS loader and the class comes back unscoped, so the symptom is silent global leakage rather than a build failure. Confirm your installed version with npx esbuild --version before debugging a scoping bug; if it prints anything below 0.18, no config change will help and the fix is the upgrade. Pin the version in package.json so a lockfile drift does not quietly reintroduce the old behavior.

The :global escape hatch is the one place scoping is intentionally suspended, and its blast radius is easy to underestimate. A :global(.is-open) selector emits .is-open verbatim into the global stylesheet, so any component that also styles .is-open now shares that rule — which is the point when you are targeting a class set by a third-party widget, and a bug when you reached for it to work around a scoping confusion you did not fully diagnose. The rule of thumb: use :global only for names you do not own, and never to “fix” a class that should have been scoped. To confirm intent, grep the output for the bare name; if it appears unhashed, you meant it to be global.

composes is a compile-time reference, not a runtime one, so a wrong path fails the build rather than degrading at runtime — which is the good outcome. When composes: base from './shared.module.css' resolves, esbuild concatenates the referenced class’s scoped name into the composing class’s exported value, so styles.button may map to two space-separated hashed names. The failure mode to watch is composing from a plain .css (non-module) file: there is no scoped name to compose, and the reference does not resolve to anything useful. Keep composes sources on .module.css files and let the build error be your check.

The silent-collision case for plain CSS is the reason the whole feature exists, and it deserves a moment of paranoia in review. Two non-module stylesheets that each declare .button produce valid CSS with no warning from esbuild — the loader is not wrong, global CSS genuinely has one namespace — and the last rule in bundle order wins. Because bundle order tracks the dependency graph, the winner can change when an unrelated import is added, so the regression surfaces on a commit that never touched either stylesheet. The structural fix is to keep component styles in .module.css and reserve plain .css for deliberately global concerns like resets and design tokens.

A worked example ties :global and composes together. Given a shared module and a component module:

/* shared.module.css — esbuild 0.25.x. A base class other modules compose. */
.base { padding: 8px 12px; border-radius: 6px; }
/* button.module.css — esbuild 0.25.x. Composes the base, opts one name out. */
.button {
  composes: base from './shared.module.css';
  background: rebeccapurple;
  color: white;
}
/* Global target: a class set by code we do not own, left unscoped on purpose. */
:global(.is-loading) .button { opacity: 0.5; }

Here styles.button resolves to two scoped names — the composing class plus shared.module.css’s scoped .base — while .is-loading ships unhashed so an external loading-state toggle can reach it. Build this and grep dist/app.css: you should see the hashed .button and .base names, and a bare .is-loading alongside them, which is the exact split the two mechanisms are supposed to produce.

Performance considerations

CSS Modules scoping is close to free relative to the rest of the build because it rides on a parse esbuild already does — there is no second tool spawning a second parse of the stylesheet, which is the main cost of the postcss-modules route under other bundlers. The scoping walk is linear in the number of selectors, and the hashing is a cheap function of the file path, so on a codebase with hundreds of .module.css files the CSS Modules work is not the bottleneck; the CSS parse and minify dominate, and those run whether or not scoping is on. The one measurable cost is that every scoped file must be parsed even if only one of its classes is imported — esbuild scopes the whole stylesheet, not just the referenced selectors — so a large stylesheet with one used class still pays its full parse. Split genuinely unrelated concerns into separate module files rather than one omnibus stylesheet if parse time shows up in the profile.

When not to use this

Reach for a plain global .css file, not a module, when the styles are meant to be global: CSS resets, @font-face declarations, CSS custom properties defined on :root, and third-party stylesheets you import wholesale. Forcing those through local-css either scopes names that other code expects to find globally or forces you to wrap everything in :global, at which point the module is doing nothing but adding a hash step. CSS Modules also add little for a page with a handful of styles and no collision pressure — a landing page with one stylesheet gains nothing from scoping and loses the ability to reference classes by literal name from hand-written HTML. The feature earns its keep when many independently-authored components share a bundle; below that threshold the indirection of the class map is overhead, not safety.

Comparison with postcss-modules

The behavior esbuild ships is deliberately a subset of what postcss-modules exposes. postcss-modules lets you supply a generateScopedName function, choose the export format, emit a JSON manifest, and plug into a wider PostCSS chain; esbuild fixes the scoping scheme and generates the JS export internally with no manifest. The practical consequences: if your build depends on a custom scoped-name algorithm — a specific [folder]__[local] template your CSS selectors or snapshot tests hard-code — esbuild’s built-in loader will not reproduce it, and you either adapt the expectations or run PostCSS as a pre-step and hand esbuild already-scoped global-css. If you have no such dependency, the built-in path removes a plugin, a config file, and a parse from the pipeline, which is why it is the default recommendation for greenfield esbuild setups. The decision comes down to whether you need postcss-modules’ configurability enough to pay for the extra tool; most builds do not.