
Using CSS Modules in Next.js: Scoped Styles Made Simple
Global CSS has one big problem: every class name lives in the same namespace. Name something .card or .title and sooner or later another component, a third-party stylesheet, or a teammate's code uses the same name, and styles start leaking in ways that are hard to trace.
CSS Modules fix that with almost no learning curve. You write normal CSS in a file ending in .module.css, import it into a component, and Next.js rewrites every class name to something unique. There's no runtime library, no new syntax, and it works the same in Server Components and Client Components.
In this post I'll cover how CSS Modules work in the Next.js App Router, how to combine and compose classes, patterns for variants and dynamic values, when to reach for :global, how TypeScript sees your modules, the Turbopack-specific caveats, and where CSS Modules fit next to Tailwind.
How CSS Modules Work
Create a file with the .module.css extension next to your component:
/* app/components/card.module.css */
.card {
padding: 1.5rem;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
background: white;
}
.title {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
.body {
color: #4b5563;
line-height: 1.6;
}
Import it as an object and use its properties as class names:
// app/components/card.tsx
import styles from "./card.module.css";
type CardProps = {
title: string;
children: React.ReactNode;
};
export function Card({ title, children }: CardProps) {
return (
<article className={styles.card}>
<h2 className={styles.title}>{title}</h2>
<div className={styles.body}>{children}</div>
</article>
);
}
At build time, Next.js transforms each class into a unique name, something like card-module__a1b2c3__card, and the styles object maps your original names to the generated ones. The rendered HTML contains only the generated names, and the CSS file contains the same generated selectors. Another component can have its own .title class without any conflict.
A few things to know up front:
- No setup is needed. Next.js supports CSS Modules out of the box with both Turbopack (the default bundler in Next.js 16) and webpack.
- It works in Server Components. The
stylesobject is a plain object of strings resolved at build time, so there's no client JavaScript involved.card.tsxabove has no"use client"and doesn't need one. - Only classes (and IDs) are scoped. Element selectors like
h2orainside a module still apply globally, so always anchor them to a local class (.card h2), or better, give the element its own class. - Custom properties aren't scoped. A
--accentvariable declared in a module is a normal CSS variable. That's usually what you want, since it lets themes and parents pass values in.
Naming Classes
Because class names become JavaScript property names, camelCase is the most convenient:
.cardHeader {
display: flex;
}
<header className={styles.cardHeader} />
Kebab-case works, but you need bracket notation:
<header className={styles["card-header"]} />
Pick one convention per project. camelCase reads better in components and lets your editor autocomplete property names.
Combining Classes
Often an element needs more than one class. Since they're just strings, you can join them with a template literal:
<button className={`${styles.button} ${styles.primary}`}>Save</button>
That gets messy with conditions. The tiny clsx package handles conditional classes cleanly:
npm install clsx
// app/components/button.tsx
import clsx from "clsx";
import styles from "./button.module.css";
type ButtonProps = React.ComponentProps<"button"> & {
variant?: "primary" | "secondary";
size?: "sm" | "md";
};
export function Button({
variant = "primary",
size = "md",
className,
...props
}: ButtonProps) {
return (
<button
className={clsx(
styles.button,
styles[variant],
size === "sm" && styles.small,
className,
)}
{...props}
/>
);
}
/* app/components/button.module.css */
.button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 0;
border-radius: 0.5rem;
padding: 0.625rem 1rem;
font: inherit;
font-weight: 500;
cursor: pointer;
}
.primary {
background: #2563eb;
color: white;
}
.primary:hover {
background: #1d4ed8;
}
.secondary {
background: #f3f4f6;
color: #111827;
}
.secondary:hover {
background: #e5e7eb;
}
.small {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
styles[variant] looks up styles.primary or styles.secondary from the prop. The className prop goes last so callers can add their own classes for layout, such as margins.
Variants with Data Attributes
An alternative to one class per variant is a data attribute. It keeps the component's class list short and makes the state visible in DevTools:
// app/components/badge.tsx
import styles from "./badge.module.css";
type BadgeProps = {
tone?: "neutral" | "success" | "danger";
children: React.ReactNode;
};
export function Badge({ tone = "neutral", children }: BadgeProps) {
return (
<span className={styles.badge} data-tone={tone}>
{children}
</span>
);
}
/* app/components/badge.module.css */
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
background: #f3f4f6;
color: #374151;
}
.badge[data-tone="success"] {
background: #dcfce7;
color: #166534;
}
.badge[data-tone="danger"] {
background: #fee2e2;
color: #991b1b;
}
The attribute selectors are anchored to .badge, which is scoped, so they can't leak. This pattern works especially well for state that also matters for accessibility, like aria-expanded or aria-current, because you can style the ARIA attribute directly instead of adding a parallel class.
Composing Classes
CSS Modules have a composes keyword that makes one class include another. It's useful for sharing base styles without repeating declarations:
/* app/components/alert.module.css */
.base {
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid transparent;
}
.info {
composes: base;
background: #eff6ff;
border-color: #bfdbfe;
}
.error {
composes: base;
background: #fef2f2;
border-color: #fecaca;
}
Now styles.info contains two generated class names, the one for .base and the one for .info, so the component only applies a single property:
<div className={styles.error} role="alert">
Something went wrong.
</div>
You can also compose from another module file:
/* app/components/panel.module.css */
.panel {
composes: surface from "../styles/shared.module.css";
padding: 2rem;
}
With Turbopack, the file you compose from must itself be a .module.css file. With webpack, composing from a plain .css file treated it as a module; Turbopack always treats a plain .css file as global CSS. If you migrate a project and composes stops working, rename the source file to .module.css.
Escaping the Scope with :global
Sometimes you need to style something you don't control: markup rendered from Markdown, a third-party widget, or a class added by a library. Wrap those selectors in :global(...):
/* app/blog/[slug]/post.module.css */
.content {
font-size: 1.125rem;
line-height: 1.75;
}
.content :global(h2) {
margin-top: 2.5rem;
font-size: 1.5rem;
}
.content :global(pre) {
overflow-x: auto;
padding: 1rem;
border-radius: 0.5rem;
}
.content :global(.highlight) {
background: #fef9c3;
}
.content is scoped, and everything inside :global() is left as written. Anchoring the global part under a local class keeps the effect contained to this component's subtree, which is the main thing you want from a module.
Turbopack supports the function form, :global(...). The standalone form, a bare :global followed by selectors, isn't supported, so stick with parentheses. The same goes for :local.
If you find yourself writing mostly :global selectors in a module, the styles are probably global by nature and belong in a global stylesheet imported from the root layout.
Dynamic Values with CSS Variables
CSS Modules are static: class names are fixed at build time. When a value comes from props or data (a progress percentage, a brand color from a CMS), don't try to generate classes. Pass a CSS custom property through the style prop and use it in the module:
// app/components/progress.tsx
import styles from "./progress.module.css";
type ProgressProps = {
value: number;
color?: string;
};
export function Progress({ value, color = "#2563eb" }: ProgressProps) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div
className={styles.track}
role="progressbar"
aria-valuenow={clamped}
aria-valuemin={0}
aria-valuemax={100}
style={
{
"--progress": `${clamped}%`,
"--bar-color": color,
} as React.CSSProperties
}
>
<div className={styles.bar} />
</div>
);
}
/* app/components/progress.module.css */
.track {
height: 0.5rem;
border-radius: 999px;
background: #e5e7eb;
overflow: hidden;
}
.bar {
width: var(--progress, 0%);
height: 100%;
background: var(--bar-color, #2563eb);
transition: width 300ms ease;
}
The cast to React.CSSProperties is needed because TypeScript's style type doesn't include arbitrary custom properties. The variables cascade to .bar, so the stylesheet stays fully static while the values change per instance. This also works in Server Components.
Media Queries, Nesting, and Keyframes
Everything you can write in regular CSS works in a module. Media queries and container queries sit alongside your classes:
/* app/components/grid.module.css */
.grid {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
Native CSS nesting works too. Turbopack processes CSS with Lightning CSS, which supports nesting and lowers it for older browsers based on your browserslist targets:
/* app/components/nav.module.css */
.link {
color: #4b5563;
text-decoration: none;
&:hover {
color: #111827;
}
&[aria-current="page"] {
color: #2563eb;
font-weight: 600;
}
}
Keyframe names are scoped like class names, so two modules can both define @keyframes fadeIn without clashing:
/* app/components/toast.module.css */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(0.5rem);
}
to {
opacity: 1;
transform: none;
}
}
.toast {
animation: fadeIn 200ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
TypeScript and CSS Modules
Next.js includes type declarations for *.module.css imports (through next-env.d.ts), so importing a module in a .tsx file type-checks without any setup. The type is a generic string map, though: styles.typo compiles without error and simply evaluates to undefined at runtime, which produces an element with no class.
That's usually fine in practice, because a missing style is obvious on screen. If you want stricter checking, editor plugins like typescript-plugin-css-modules provide autocomplete and flag unknown class names in your editor, without changing the build.
Using Sass with CSS Modules
If you use Sass, the same rules apply with a .module.scss extension. Install the compiler:
npm install -D sass
// app/components/hero.module.scss
$space: 1.5rem;
.hero {
padding: $space * 4 $space;
.heading {
font-size: clamp(2rem, 5vw, 3.5rem);
}
}
The import and usage are identical: import styles from "./hero.module.scss". One Turbopack note: custom Sass functions (sassOptions.functions) aren't supported, since Turbopack can't call JavaScript functions from its Rust core. Plain variables, mixins, and partials all work. For broader Sass setup, see How to Integrate CSS and Sass in Next.js.
CSS Ordering
Next.js bundles CSS into chunks, and the order of rules in the final output follows the order of your imports. That matters when two modules style the same element, typically a base component and a caller passing a className:
// app/page.tsx
import { Button } from "./components/button";
import styles from "./page.module.css";
export default function Page() {
return <Button className={styles.wide}>Continue</Button>;
}
Because Button (and its module) is imported before page.module.css, the page's rules come later and win when specificity is equal. If you reorder those imports, the result can change. A few habits keep this predictable:
- Keep each component's CSS import inside the component file.
- Don't let tools auto-sort imports in a way that moves CSS imports.
- Prefer overrides that don't rely on order, such as adding a layout-only class (margins, width) rather than overriding colors or padding.
- Verify with
next buildandnext start, because CSS ordering in development can differ from production.
CSS Modules vs Tailwind vs Global CSS
Next.js supports all three, and most real projects use more than one:
| Approach | Best for | Trade-offs |
|---|---|---|
| Global CSS | Resets, base typography, CSS variables, third-party styles | Everything shares one namespace |
| CSS Modules | Component styles, complex selectors, animations | Separate file per component, class names in JS |
| Tailwind CSS | Most layout and styling in markup | Long class lists, learning the utility names |
A common combination is Tailwind for most styling, global CSS for tokens and base styles, and CSS Modules for the handful of components with selectors that are awkward as utilities (complex :has() rules, keyframe animations, styling rendered Markdown). If you combine Tailwind v4 with modules and want to use @apply inside a module, add @reference to your main stylesheet at the top of the module; the Tailwind v4 setup guide shows how.
If you aren't using Tailwind, CSS Modules plus a global file of custom properties is a complete, dependency-free styling system.
Common Mistakes
- Using an element selector at the top level of a module.
a { color: red }in a module is global. Scope it under a class. - Expecting class names from strings to work.
className="card"doesn't match the hashed.card. Always go throughstyles.card. - Generating class names dynamically.
styles[`size-${n}`]only works if every possible class exists in the file. For continuous values, use CSS variables. - Composing from a
.cssfile under Turbopack. Rename it to.module.css. - Writing bare
:global. Use the function form,:global(.selector).
Conclusion
CSS Modules give you real CSS with automatic scoping, and in Next.js they need zero configuration. Name a file .module.css, import it, and use styles.className. They work in Server Components with no runtime cost.
From there, a handful of patterns cover nearly everything: clsx for combining classes, data attributes for variants, composes for shared base styles, :global(...) anchored under a local class for markup you don't control, and CSS variables through the style prop for dynamic values. Keep imports in a predictable order, remember Turbopack's small differences, and modules will stay one of the least surprising parts of your styling stack.


