The Problem
I hit this on a client dashboard refactor last Friday. We had just upgraded the app to Next.js 16.1 with Turbopack as the default dev bundler. Edit a .module.css file, save, and the browser would refresh — but the styles never updated. Hard refresh, same stale CSS. Stop the dev server, restart it, and the new styles finally appeared.
If your team is on Turbopack in dev and someone keeps asking why CSS edits "do nothing" until a restart, this is it. The HMR signal fires, the page reloads, and the CSS modules dependency graph stays cached at the boundary where you import the module.
Why It Happens
Turbopack handles CSS modules by hashing class names per-file and rewriting the imports at build time. The hash is what you see on the rendered element (something like _pricing-card__title__abc123). On a content edit inside a class that already exists, HMR works fine — the class names stay the same and the file gets replaced atomically.
The breakage shows up in three specific cases:
- You add or remove a class name in the module file. The hash for the new class is generated, but the importing component does not re-evaluate its import map. The compiled component still references the old hash table. The CSS file in the network tab has the new class, the rendered HTML has the old hash, and nothing matches. There is an open tracking issue on the Next.js repo for this exact behavior and it has been there since the 16.0 release.
- You import the component through a barrel file (
index.ts). Turbopack's module graph for CSS is conservative about traversing re-exports. Ifcomponents/index.tsre-exportsPricingCard, which itself importsPricingCard.module.css, an HMR update on the CSS does not propagate up through the barrel. The page refreshes, but the boundary that should re-import the module is the page itself, not the deeply-imported component. The result is a phantom reload that does nothing useful. - Server Components and Client Components in the same tree. A Server Component importing a CSS module gets the styles bundled into the route-level CSS chunk. A Client Component bundles them per-component. If you have both in one tree, HMR may update one and leave the other stale — and you will see half-updated styles, which is more confusing than no update at all.
The underlying cause is that Turbopack's CSS module dependency tracking is not fully bidirectional yet. The Webpack-based bundler in Next.js 15 handled this with a heavier invalidation graph. Turbopack is faster precisely because it does not — at the cost of these edge cases.
The Fix
Step 1: Confirm it is Turbopack, not your config. Add a Webpack fallback script and reproduce side by side:
{
"scripts": {
"dev": "next dev --turbopack",
"dev:webpack": "next dev --no-turbo"
}
}
If pnpm dev:webpack updates styles on save and pnpm dev does not, you have confirmed the Turbopack HMR gap and can stop chasing your own code.
Step 2: Avoid barrel re-exports for any component that imports a CSS module. Import the component directly. This is the single biggest fix for most teams and it also speeds up cold builds:
// Bad — HMR for the CSS module will not propagate
import { PricingCard } from '@/components';
// Good — direct import keeps the module graph shallow
import { PricingCard } from '@/components/PricingCard';
If you have a large barrel file with hundreds of exports, a codemod is worth the afternoon. Run this once and your dev experience improves measurably:
npx jscodeshift -t scripts/unbarrel.ts --extensions=ts,tsx src/
Vercel's App Router CSS docs recommend direct imports for build performance, and it doubles as the HMR fix.
Step 3: Co-locate the CSS module with the component, no path aliases. Turbopack tracks file changes by absolute path. If you have an @styles alias pointing to src/styles/, edits there will not always trigger HMR for components importing through the alias:
// Bad — aliased CSS imports
import styles from '@styles/PricingCard.module.css';
// Good — relative path next to the component
import styles from './PricingCard.module.css';
Step 4: For new class names specifically, use a stable wrapper first. When you need to introduce a brand-new class to the module, Turbopack's hash invalidation does not always wake the importing component. Workaround: define the new class as a child of an existing top-level selector, reload once, then move it around freely after the file is "seen" in the graph.
/* PricingCard.module.css */
.card {
/* existing styles */
}
/* New class — add it inside an existing wrapper first */
.card .badge {
position: absolute;
top: -8px;
right: -8px;
}
This is a workaround, not a real fix. The actual fix is upstream and will land in a future patch release.
Step 5: Force a graph rebuild on stuck HMR without restarting. When you do see stale styles, do not blindly kill the dev server. touch the importing file:
touch src/components/PricingCard/PricingCard.tsx
That is faster than a full restart and preserves your dev server's RSC payload cache, which itself can take 30 seconds to warm up on a large app. Wire it into a keybinding if your editor supports running shell commands.
Step 6: Track the upstream fix. Pin a follow-up in your tracker. The Next.js team is actively reworking Turbopack's CSS dependency graph and the HMR invalidation gap is on the roadmap. Once a patch release ships, you can remove the barrel codemod's exemption list and go back to whatever import style you prefer.
The Lesson
Turbopack's CSS module HMR has a known invalidation gap that surfaces when class names are added or removed, when modules are reached through barrel exports, or when Server and Client Components share a tree. Until the upstream fix lands, the workaround stack is: direct imports over barrels, relative paths over aliases for CSS modules, and a touch on the importing file instead of a full restart.
If your team upgraded to Next.js 16 and dev velocity has dropped because of bundler edge cases like this one, that is the kind of cleanup I do for clients — see my services. For a related Turbopack issue I covered, see Next.js Turbopack linked package fix.