Overview

Use CSS Modules for new React projects. They produce zero runtime overhead, work with static extraction at build time, and integrate cleanly with SSR including React Server Components. Use styled-components only when dynamic runtime theming (theme values computed at runtime per user, per tenant, or per user action) is a genuine product requirement that cannot be served by CSS custom properties. See tailwind-vs-css-modules for the adjacent decision between utility-first and module CSS.

When CSS Modules wins

CSS Modules are the default for new projects.

  • Zero runtime: styles are extracted to static CSS files at build time. No JavaScript runs at paint time to inject styles. This is a meaningful improvement for Core Web Vitals (Cumulative Layout Shift, Total Blocking Time).
  • SSR and RSC compatible: CSS Modules work in Next.js App Router Server Components. styled-components requires the client boundary.
  • Scoping: class names are hashed to the file scope automatically. No naming collisions across components.
  • Familiarity: the file is plain CSS. Designers, non-frontend engineers, and accessibility auditors can read it without knowing JavaScript or the styled-components API.
  • Tooling: Vite, Webpack, and Next.js support CSS Modules out of the box. No additional packages.
  • Composition: composes: base from './base.module.css' enables style reuse without duplication.
// Button.module.css
.button { padding: 8px 16px; border-radius: 4px; }
.primary { background: var(--color-primary); color: white; }
 
// Button.tsx
import styles from "./Button.module.css";
export const Button = ({ variant = "primary", children }) => (
  <button className={`${styles.button} ${styles[variant]}`}>{children}</button>
);

When styled-components wins

styled-components is justified when runtime theming is a real product constraint.

  • Per-user runtime themes: if theme values (colors, spacing, typography) change at runtime based on user preferences, tenant branding, or live product configuration, styled-components injects the computed CSS in a single paint. CSS custom properties achieve this in many cases too, but styled-components handles deeply nested dynamic values more naturally.
  • Interpolated logic: background: ${(p) => p.active ? colors.active : colors.default} ties visual state directly to props without class-name toggling. Useful when state combinations are numerous.
  • Design system libraries: packages that export components to consumers without a bundler assumption often use styled-components so consuming apps do not need to configure CSS Module support.
  • Existing codebase: a large codebase already on styled-components v6 with an active team. Do not rewrite for its own sake.

Note: styled-components v6 adds a Babel/SWC plugin for SSR support, but the extra dependency chain and client-only boundary remain costs.

Trade-offs at a glance

DimensionCSS Modulesstyled-components
Runtime overheadNone (static extraction)JS runtime for style injection
SSR / RSC compatibleYesRequires client boundary in RSC
Core Web VitalsBetter (no style flicker)Risk of FOUC without SSR plugin
Bundle sizeStyles in CSS file; no JS added~12 KB JS runtime
Dynamic runtime themesCSS custom properties (limited)First-class via props and ThemeProvider
TypeScript integrationManual typed class names (@types)Typed props on template literals
Colocation.module.css alongside componentTemplate literal in same file
Readability for designersPlain CSS fileJavaScript with CSS-in-JS syntax
Vite/Webpack supportBuilt-inPlugin or SWC transform
DebuggingClass names in DevToolsGenerated class names; display name helps
TestingClass name assertionsRendered styles assertions

Migration cost

styled-components to CSS Modules migration is file-by-file and safe to do incrementally.

  • Extract each styled.* component to a .module.css file. Convert interpolated logic to CSS custom properties or conditional class names.
  • Replace ThemeProvider with CSS custom properties on :root or a data attribute. Most theme needs are served by this pattern.
  • Remove styled-components from package.json once all components are migrated. Add @types/css-module if TypeScript class name inference is needed.
  • Effort: one engineer-day per 30 styled components. Themes with complex runtime logic take longer.

Recommendation

  • New React project: CSS Modules.
  • Next.js App Router project: CSS Modules. styled-components requires the "use client" boundary and loses RSC benefits.
  • Project with true per-tenant runtime theming (SaaS with custom brand colors): styled-components or CSS custom properties approach. Evaluate CSS custom properties first.
  • Existing styled-components project working well: stay. Migrate on a major UI refactor, not as a standalone project.
  • Alternative worth evaluating: tailwind-vs-css-modules; Tailwind eliminates the choice entirely for many teams.