CSS Modules vs. Sass: Two Classes Say Neither Is Needed Here
CSS Modules scopes class names; Sass preprocesses them — different jobs, often combined. This repo's entire hand-written stylesheet has two custom classes, and one is dead code.
CSS Modules solves one problem: keeping a class name from leaking into the wrong component. Sass solves a different one: keeping hand-written CSS from repeating itself. They aren't rival ways to do the same job — CSS Modules files can be written in Sass syntax, and plenty of codebases run both. This one runs neither, and grep says exactly why: this repo's entire hand-written stylesheet defines two custom class names, one of which nothing currently uses.
The short answer
CSS Modules gives every class name in a file a build-time-generated, collision-proof identifier (.button_a3f01), so two components can both define .button without one clobbering the other. Sass adds variables, nesting, mixins and file-splitting on top of plain CSS syntax, compiling down to a single stylesheet before the browser ever sees it. Reach for CSS Modules when multiple authors are writing component-scoped CSS by hand and naming collisions are a real risk. Reach for Sass when a hand-written stylesheet has grown enough repetition that variables and mixins pay for themselves. This repo has neither problem, because it has almost no hand-written CSS in the first place.
The comparison that matters
| CSS Modules | Sass | |
|---|---|---|
| What it solves | Class-name collisions between components | Repetition and structure in hand-written CSS |
| Mechanism | Build-time class name hashing (.button → .button_a3f01) | A compiler (dart-sass) that expands $variables, @mixin, @use into plain CSS |
| Needs a naming convention on its own | No — the tool generates the unique name | Yes — Sass has no scoping mechanism; two .button rules in two .scss files still collide unless you namespace by hand |
| Can be combined | Yes — .module.scss runs both at once | Yes — the same file |
| Works in a Server Component | Yes, same as any static CSS import | Yes, same as any static CSS import |
| Build step | A bundler loader (webpack, Vite's CSS Modules support) | A separate compiler, or a bundler plugin wrapping one |
| What replaces it here | Tailwind utility classes, applied directly in JSX | Tailwind's @theme tokens — see Sass vs. Tailwind |
The "needs a naming convention on its own" row is the one that gets lost when people treat these as alternatives: Sass doesn't scope anything. A project using only Sass still needs BEM or a similar convention to avoid the exact collision CSS Modules exists to prevent automatically.
Why "CSS Modules vs. Sass" is the wrong frame for what each one is
Search for the phrase and the honest answer sits right in the results: CSS Modules can be written in Sass. .module.scss is a normal file extension — Sass compiles the nesting and variables, then the bundler's CSS Modules loader hashes the resulting class names. They stack in the same pipeline because they operate on different axes: Sass authors the rules, CSS Modules names them. Treating the choice as either/or only makes sense once you've already decided you're not using Sass — at which point the real question becomes "does this component need scoped class names," which is a CSS Modules question with no Sass in it at all.
That's the question this repo can actually answer, because it has an unusually small amount of hand-authored CSS to check it against.
This repo's entire custom stylesheet: two classes
src/app/globals.css is 133 lines. Past the @theme token block (42 custom properties: the primary and gray color scales, plus semantic tokens like text-color) and one @layer base rule for the body font, exactly two author-chosen class names exist in the whole file:
/* src/app/globals.css */
.container {
width: 100%;
margin-inline: auto;
padding-inline: 1rem;
}
/* + 7 @media blocks, one per breakpoint, each overriding max-width or padding-inline */
@layer utilities {
/* Hide scrollbar for horizontal carousels */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
.container has exactly one call site in the entire codebase — not one usage, one call site — because it's wrapped in an atom:
// src/components/atoms/Container.tsx
export default function Container({ className = "", children }) {
return <div className={`container ${className}`.trim()}>{children}</div>;
}
Every page that needs the responsive container imports Container, not the class name directly. There is no second component anywhere in this repo that could define its own .container and collide with this one — the class is deliberately global and single-source, which is the opposite of what CSS Modules is for. Scoping a class that has exactly one point of definition and one point of use protects against a collision that structurally cannot happen.
.no-scrollbar is the more interesting case, because grepping for it turns up nothing:
$ grep -rn "no-scrollbar" src/components src/app --include=*.tsx
$ echo $?
1
No matches. The class exists in globals.css, declared for hiding a scrollbar on horizontal carousels, but the component that would use it — Carousel.tsx — reaches for a plain overflow-hidden wrapper instead:
// src/components/organisms/Carousel.tsx
<div className="group relative flex w-full overflow-hidden py-8" style={{ ... }}>
overflow-hidden clips the overflow entirely rather than allowing a scrollable-but-scrollbar-free row, which is a different technique for a different situation — but the practical result is that .no-scrollbar has zero live call sites in this checkout. One of this codebase's two hand-written classes is dead CSS. Nobody noticed, because a 5-line unused rule in a 133-line file costs nothing to ship and nothing alerts you to it — which is itself the answer to "would CSS Modules have caught this": no, scoping only prevents a class from being used in the wrong place; it does nothing about a class not being used at all.
What that two-class inventory means for the comparison
CSS Modules earns its keep when a codebase has many author-written classes across many files, written by people who might reuse the same short name (.card, .wrapper, .active) without coordinating. This repo doesn't have that shape at all — it has two custom classes, one used from a single call site by design, one currently unused. There is no collision risk to manage because there is nothing left to collide. Sass earns its keep when a stylesheet's hand-written rules repeat enough that variables and mixins pay for their build step; Sass vs. Tailwind already measures that side of it — the same 133-line file, and the finding that Tailwind's @theme tokens already do what Sass's $variables would.
Put the two together and the reason this repo runs neither tool isn't an oversight — it's that Tailwind's utility-class model resolved both axes before either question came up. Scoping is moot when the classes in JSX are shared utility names by design, not per-component identifiers meant to be unique. Preprocessing is moot when there's no repeated hand-written CSS left to preprocess. Two problems, one underlying cause, zero dependencies.
Mistakes and troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Two components' .card styles conflict after a merge | Both authored the same class name without a scoping mechanism or naming convention | Rename with BEM, or switch the file to CSS Modules so the bundler generates unique names |
A .module.scss file's classes show up as undefined in the component | The import was destructured wrong, or the loader isn't configured for the .scss extension specifically (not just .css) | Confirm the bundler's CSS Modules loader is registered for .module.scss, not only .module.css |
| A Sass mixin's output collides with another file's class of the same name | Sass has no built-in scoping — two .scss files can both emit .button | Either namespace manually or add CSS Modules on top (.module.scss) to get both features |
| A hand-written utility class ships in the bundle but nothing renders with it | No build step flags an unused custom class the way it flags an unused import | Grep the class name across the component tree before assuming a hand-written CSS rule is load-bearing |
Migrating from Sass to Tailwind, .container-style global classes break | A single hand-rolled global class used everywhere has no per-component ownership, so partial migration leaves some components on the old class and some on new utilities | Wrap the old class in one shared component (an atom) so every caller migrates together, not file by file |
Frequently asked questions
Can CSS Modules and Sass be used together?
Yes, and it's a common pairing — a .module.scss file gets Sass's compile-time features (variables, nesting, mixins) and the bundler's CSS Modules loader still hashes the resulting class names for scoping. They aren't mutually exclusive; the "vs." framing only makes sense once you've decided against Sass specifically.
Does Tailwind replace both CSS Modules and Sass?
It replaces the reasons most projects reach for either. Sass's variables and nesting are subsumed by @theme tokens and utility composition; CSS Modules' scoping is unnecessary when the classes in your markup are shared utilities rather than component-authored names meant to be unique. Neither replacement is automatic — a project with heavy custom CSS still needs one or the other.
If I have zero custom CSS classes, do I need either tool? No — and that's this repo's actual position. Two custom classes, one used from a single call site, one currently unused, is not enough surface area to justify either a preprocessor or a scoping mechanism. The break-even point is somewhere past "a handful of one-off overrides," not before it.
Would CSS Modules have caught the unused .no-scrollbar class?
No. CSS Modules prevents a class name from being used in the wrong place; it has no mechanism for flagging a class that's declared but never referenced anywhere. That's a dead-code problem, and the fix is the same one that works without either tool: grep the class name against the component tree periodically.
Templates in this post
ASoc Guard is a landing template for an AI-driven cyber-security platform, leading with a live-metrics hero and a six-app integrations row. ASoc Haven is a real-estate landing template with a filterable listings grid, agent profiles, and a free home-valuation lead form. ASoc Hearth is an AI smart-home landing page with an eight-tile capability grid and three pricing tiers from single-room to whole-estate.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
