This started right after we shipped a production release of our feature. One of our engineers was running through the change to confirm everything looked right, and the issue caught his eye — since it's a UI thing, it was easy to spot. The CSS width of some components was broken. In the development environment everything had been fine, working perfectly. But in production it wasn't. He reported it. So I started digging.
The first instinct was the wrong one. I dropped an !important on the rule, the width snapped back to what it should be, and I almost called it a day. Then I realized I'd just patched a symptom without understanding the cause — and a bug that behaves differently between dev and prod is exactly the kind of bug that comes back six months later in a different file. So I rolled the !important back out and tried to actually figure out what was happening.
What was colliding
The element in question was a form label. It was being styled by two CSS Module rules at the same time:
/* shared component — time-range-group-field.module.css */
.label { width: 20.75rem; }
/* my page(comsumer) — scheduler-form.module.css */
.fieldLabel { width: 11rem; }
The shared component does this internally:
/* styles.label is the shared component's own class from its CSS module.
* labelClassName is whatever class the caller passed in.
* mergeClassNames is just a helper that glues class strings together — same idea as clsx.
* The element ends up with both classes on it.
*/
<label className={mergeClassNames(styles.label, labelClassName)} >My Label</label>
So both classes were ending up on the same element. The browser now has to decide which width wins. Two rules, both at specificity (0,1,0) — one class each. A clean tie. And on a tie, CSS falls back to source order: whichever rule appears later in the final stylesheet wins.
In dev mine appeared later, so 11rem won. In production the shared rule appeared later, so 20.75rem won. Same code. Different outcomes.
The build had flipped the order on me.
How I confirmed it
I built and grepped the output to make sure I was right about which rule was winning:
npm run build
grep -E "11rem|20.75rem" out/_next/static/chunks/<the-file>.css
Sure enough, both rules were sitting inside the same CSS chunk file. Mine came first, theirs came second. The tie-breaker said "later wins." Mine was "earlier." Mine lost.
DevTools confirmed it too. Inspect the element in production, open the Styles panel, my rule was struck through. Hovering it, Chrome politely informed me it had been overridden by a rule with equivalent specificity that appeared later in the stylesheet.
The browser was doing exactly what it was supposed to. I just hadn't been paying attention to which rule it considered "later."
Why dev and prod differ
This part was the most interesting thing I learned from the whole bug.
In development, Next.js doesn't bundle CSS. Each .module.css is injected at runtime as its own <style> tag, in the order JavaScript imports it. JavaScript imports go depth-first — so the children module's CSS ends up later in the DOM. Your override CSS, sitting at the leaf, naturally wins ties. It feels intuitive. It also lies, because nothing about that ordering is guaranteed.
In production, next build runs Turbopack. It uses Lightning CSS to extract CSS Modules into chunk files in out/_next/static/chunks/. Turbopack tries to follow JS import order when ordering CSS rules, but when a shared CSS module is reachable through multiple import paths (different pages, different parents), Turbopack has to pick one — and the chosen order may not match what dev's runtime-injected <style> tags produce.
For JavaScript that's fine — every module is independently identified and execution order is mostly its own concern. For CSS it's hostile. CSS rule order is part of the semantics. The bundler had just told me that part of the semantics wasn't mine to control.
The fix
I'd seen people fix this kind of thing with !important or with .root .fieldLabel to bump specificity. Both work and both leave a mess — every future override has to fight the same fight. The cleaner fix turned out to be one CSS function I'd never used before: :where().
I changed the shared component's rule to:
:where(.label) {
width: 20.75rem;
padding-top: 0.5rem;
}
:where(...) takes whatever selector you put inside and forces its specificity to zero. The shared component's default is now the weakest rule in the entire cascade. Anyone overriding it from the outside with even a single plain class wins automatically:
Shared default: 0,0,0
My override: 0,1,0 ← always wins
No source-order dependency. No tie to break. No reliance on whichever heuristic webpack happened to use this build.
This is, in retrospect, what :where() is for. It's the CSS equivalent of a default prop value — present if no one says otherwise, replaced freely the moment anyone does. If you maintain a shared UI component, every "default" style it ships should be wrapped this way. It costs you two extra characters and it saves every downstream caller from ever needing !important.
A second knob, for defense
I also turned on experimental.cssChunking: 'strict' in next.config.ts:
experimental: {
cssChunking: 'strict',
}
This doesn't help the within-chunk race we just fixed — order inside a chunk is webpack's call regardless. But it does help with a separate, related issue: when Next.js merges CSS files imported in different orders across modules, 'strict' tells it to stop. It's Next.js's official "stop reordering my CSS" option. Worth turning on if you've hit any version of this bug once.
What I took away from it
A bug that behaves differently in dev and prod is almost always a sign that something you thought was deterministic isn't. In this case it was source order — something I'd been quietly relying on for years without noticing.
The way out, if there's a general lesson here, is to never write a fight between two equally-specific selectors. Pick a winner, on purpose. Either give your selector specificity that earns the win, or wrap the other one in :where() to demote it. Letting the cascade decide for you by accident is fine until the day a bundler version changes and it's not.
Two characters of :where() would have saved me an afternoon.


Top comments (0)