Skip to main content
ASoc
Comparison

CSS Modules Alternatives: What a 0-Module Codebase Ships Instead

Utilities, CSS-in-JS, BEM or native @scope: what each gives up. This repo has no module files and ships one 83 KB stylesheet (14.8 KB gzipped) for every page.

The ASoc Team7 min read

The realistic alternatives to CSS Modules are utility classes (Tailwind), CSS-in-JS, a naming convention such as BEM, and native CSS features like @scope and @layer. Which one fits depends on what you want to keep: CSS Modules gives you hashed, per-file class names. This storefront dropped hand-written component CSS altogether, and its whole production stylesheet is 83,365 bytes (14,803 gzipped), shared by every page.

This post is the "what did we pick instead, and what did it cost" version. The numbers below come from a npm run build of this repository, not from a benchmark suite.

The short answer

If you want...Reach forWhat you give up
Collision-proof class names, still writing plain CSSCSS Modules (stay put)Nothing, but you keep authoring a stylesheet per component
No stylesheet authoring at allTailwind utility classesLong className strings; a design-token discipline
Styles that depend on runtime valuesCSS custom properties or a small inline styleNot "pure" static CSS
Scoping without a build toolNative @scope and @layerNewer syntax; you still write the CSS
Styles computed in JavaScriptCSS-in-JS (Emotion, styled-components)A runtime or build plugin, and Server Component friction
Zero tooling, small teamBEM naming conventionDiscipline instead of enforcement

If your complaint with CSS Modules is the ceremony (a .module.css file next to every component, an import, a styles. prefix on every class), utilities remove the ceremony. If your complaint is that you want scoping and no build step, the native route is the one to watch. If you like Modules and just want nicer variables, you probably do not need an alternative; plain CSS custom properties work inside a module.

What CSS Modules actually does

A CSS Modules file is ordinary CSS where every class name is rewritten at build time into something unique. .button in one file and .button in another stop colliding because the bundler emits .button_a3f01 and .button_9c2d7. That is the whole feature: scoping. It is not a styling system, has no design tokens, and does not help you write less CSS.

That framing matters because most "alternative" lists compare tools that solve different problems. A preprocessor (Sass) extends the syntax. A utility framework (Tailwind) removes the need to name things. CSS-in-JS moves rules into components. Only the last two are real replacements for the scoping job; the first is a companion. If you are weighing a preprocessor specifically, the repo's earlier write-ups cover it: CSS Modules vs. Sass and Sass vs. Tailwind.

What this repo uses instead

Count first:

git ls-files '*.module.css' '*.scss' '*.sass' | wc -l   # 0
find src -name '*.css'                                   # src/app/globals.css

Zero module files, zero preprocessor files, and exactly one stylesheet in src/: src/app/globals.css, which holds the @theme design tokens, the responsive .container rule and two small utilities. There are 139 .tsx files under src/, and none of them imports a stylesheet of its own. Every component styles itself with Tailwind classes in its own className.

The page-width wrapper is the clearest example of where a Module would have gone. A .container rule is global on purpose, and it is reached through a single atom so the class name is only ever written once:

// src/components/atoms/Container.tsx
export default function Container({ className = "", children }) {
  return <div className={`container ${className}`.trim()}>{children}</div>;
}

That is the compromise worth copying: if you have one or two truly global rules, keep them in one stylesheet and wrap them in a component, rather than scattering a module per component to protect a name that only exists once.

The measured cost

After npm run build, the output contains one CSS chunk:

MeasureValue
CSS files emitted in .next/static/chunks/1
Raw size83,365 bytes
Gzipped (gzip -9)14,803 bytes
Stylesheets linked from /, /pricing, /templates1 each, the same file

Two things follow from that. First, utility CSS is bounded by the vocabulary you use rather than by the number of components, so adding a page mostly reuses classes that are already in the file. A per-component CSS Modules setup grows with the component count, because each module is its own rule set even when two components share the same padding and colour. Second, one shared stylesheet means a visitor who lands on / has already downloaded the CSS for /pricing. That is a trade, not a free win: you ship rules for pages the visitor has not opened yet, and you give up per-route CSS splitting.

I am deliberately not claiming the utility approach is smaller than a well-built Modules setup. This repo has no Modules build to compare against, so there is no honest side-by-side number. What the measurement supports is narrower: with utilities, the stylesheet is one cacheable file whose size you can read off the build.

Where inline style still earns its place

Utilities cannot express a value computed at runtime, because Tailwind generates classes from source text at build time. A class like translate-x-[${index * 100}%] never gets generated. This repo has 55 style={{ sites, and the real dynamic ones are small:

// src/components/molecules/TemplateGallery.tsx
<div
  className="flex transition-transform duration-500 ease-out motion-reduce:transition-none"
  ref={trackRef}
  style={{ transform: `translateX(-${index * 100}%)` }}
>

The static parts of the slide track (flex, transition timing, the reduced-motion override) stay as utilities; only the one value that depends on the current slide index is inline. The preview modal does the same for its device frame, setting width, height and transform: scale(...) from measured stage size. If you are leaving CSS Modules and find yourself reaching for CSS-in-JS only for dynamic values like these, an inline style or a CSS custom property set from JavaScript is the lighter option.

Component-level responsiveness without a module

One thing Modules people do not expect to lose: component-scoped media queries. The answer here is container queries. A card marks itself as a query container and its children respond to the card's width rather than the viewport's:

// src/components/molecules/TechStackCard.tsx
<div className="group @container rounded-3xl border ...">

Tailwind v4 ships @container and the @md: style variants in core, so the scoped-by-component behaviour that Modules plus a media query used to approximate is a utility. For the deeper version of this, see Tailwind container queries.

Mistakes and troubleshooting

SymptomCauseFix
A dynamic class (w-[${n}px]) never appliesTailwind scans source text; interpolated class names are not generatedUse an inline style, or map to a fixed set of full class names
A global rule collides with a component after migrationThe old module name and a new global rule share a selectorPut the rule in a wrapper component, as Container does
Styles from a deleted module still appearA global stylesheet kept a copyGrep the class name across src/ before deleting it
The CSS file is larger than expectedUtilities for every page ship in one fileCheck the build output; split by route only if the number justifies it
Dark mode breaks on a migrated componentThe module used a media query, the utilities need the dark: variantAdd dark: variants alongside the light classes

Frequently asked questions

Is CSS Modules deprecated? No. It is a stable, widely supported feature in Next.js and Vite. Moving away is a preference about authoring style, not a requirement.

Can I use Tailwind and CSS Modules together? Yes. They are independent: a module for the handful of rules that are awkward as utilities, Tailwind for everything else. This repo simply does not need the module half.

What is the lightest alternative if I do not want a framework? Plain CSS with a naming convention, plus @layer to control specificity order. You keep zero dependencies and give up automatic scoping.

Does Tailwind work in Server Components? Yes. It is static CSS, so it has no runtime and no hydration cost, unlike runtime CSS-in-JS libraries.

Templates in this post

ASoc Till is a POS-system marketing site with a live register preview and app-store download calls to action. ASoc Timbre is an AI voice-generator landing page with voice samples, 170+ languages and pricing tiers. ASoc Uptime is a web-hosting site with domain search, performance tooling and tiered pricing.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Comparison9 min read

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.

Read more
Comparison8 min read

esbuild vs. Vite: What This Repo's Own Lockfile Says

Neither is a dependency here — but the Vite version vitest pulls in has already dropped esbuild for Rolldown, proven straight from the lockfile.

Read more
Comparison9 min read

Fathom vs. Vercel Analytics: Portability, Priced

Both are cookieless with a one-line install. The difference is what happens when you leave your host, measured against this site's live CSP, event wrapper and Lighthouse run.

Read more