CSS Modules
Create a Component.module.css file alongside your component and import it as a JS object. Next.js automatically scopes class names to avoid collisions.
CSS Modules in the App Router
Every Next.js project needs a styling strategy. Before you reach for a CSS framework, it's worth understanding what's built right into Next.js — CSS Modules. They're zero-config, zero-runtime-cost, work in both Server and Client Components, and solve the biggest problem with plain CSS: class name collisions.
This lesson covers CSS Modules from the ground up — how they work, how to use every feature, how to combine them with global styles, and the patterns that keep your styles maintainable as your project grows.
1. The Problem CSS Modules Solve
In a large project with many developers and components, plain CSS class names collide.
Two developers writing .button in different files will overwrite each other's
styles. Solutions like BEM naming conventions (.component__element--modifier)
help but are verbose and rely on discipline.
CSS Modules solve this automatically. Every class name you write gets transformed into
a unique, scoped identifier at build time — so .button in
Card.module.css becomes something like Card_button__x7Kp2
in the final output. Two different components can both use .button and
they will never conflict.
2. Creating and Using a CSS Module
Any file ending in .module.css is a CSS Module. Create one alongside
the component it styles:
// File structure:
components/
└── Card/
├── Card.tsx
└── Card.module.css
/* components/Card/Card.module.css */
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.title {
font-size: 1.25rem;
font-weight: 600;
color: #111;
margin-bottom: 0.5rem;
}
.body {
font-size: 0.9rem;
color: #555;
line-height: 1.6;
}
.footer {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
// components/Card/Card.tsx
import styles from "./Card.module.css";
type CardProps = {
title: string;
body: string;
footer?: React.ReactNode;
};
export default function Card({ title, body, footer }: CardProps) {
return (
<div className={styles.card}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.body}>{body}</p>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
);
}
Import the CSS Module as a JavaScript object — by convention named
styles. Each class name in the CSS file becomes a property
on that object. TypeScript gives you autocomplete for all available class names.
In the browser, inspect the element and you'll see a class name like
Card_card__a3Kf9 — the component name, the class name, and a
unique hash, all joined together. Completely scoped, no collisions possible.
3. Multiple Class Names
Applying multiple classes to one element is the most common need. Use a template
literal or the clsx library (which we'll look at shortly):
/* Button.module.css */
.button {
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
border: none;
transition: opacity 0.2s;
}
.primary {
background: #2563eb;
color: white;
}
.secondary {
background: #f1f5f9;
color: #334155;
}
.danger {
background: #dc2626;
color: white;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fullWidth {
width: 100%;
}
// Button.tsx
import styles from "./Button.module.css";
type ButtonProps = {
variant?: "primary" | "secondary" | "danger";
fullWidth?: boolean;
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
};
export default function Button({
variant = "primary",
fullWidth = false,
disabled = false,
children,
onClick,
}: ButtonProps) {
// Template literal — simple but gets messy with many conditions
const className = [
styles.button,
styles[variant],
fullWidth ? styles.fullWidth : "",
disabled ? styles.disabled : "",
]
.filter(Boolean)
.join(" ");
return (
<button className={className} onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
Using clsx for Cleaner Conditional Classes
The clsx library makes conditional class names much cleaner.
Install it once and use it everywhere:
npm install clsx
// Button.tsx with clsx
import clsx from "clsx";
import styles from "./Button.module.css";
export default function Button({
variant = "primary",
fullWidth = false,
disabled = false,
children,
onClick,
}: ButtonProps) {
return (
<button
className={clsx(
styles.button,
styles[variant],
fullWidth && styles.fullWidth,
disabled && styles.disabled,
)}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
clsx accepts any combination of strings, objects, and arrays —
falsy values are automatically excluded. It's one of the most-used utilities
in Next.js projects.
4. Composing Classes — the composes Keyword
CSS Modules have a powerful built-in feature called composes that
lets one class inherit the styles of another — like a mixin in Sass:
/* styles/shared.module.css */
.flexCenter {
display: flex;
align-items: center;
justify-content: center;
}
.card {
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* HeroCard.module.css */
.heroCard {
composes: card from "./shared.module.css"; /* ← inherits all card styles */
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
min-height: 300px;
}
.heroContent {
composes: flexCenter from "./shared.module.css";
flex-direction: column;
gap: 1rem;
}
composes doesn't duplicate the CSS — it adds both class names to the
element at runtime. The output HTML element gets both HeroCard_heroCard__xxx
and shared_card__yyy as class names. Styles stay in one place and are
reused without copy-pasting.
5. Global Styles Alongside CSS Modules
CSS Modules handle component-level styles. But some styles need to be global — CSS
resets, base typography, custom properties (CSS variables), and third-party library
overrides. These go in app/globals.css which is imported once in the
root layout:
/* app/globals.css */
/* 1. CSS Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* 2. Base styles */
body {
font-family: var(--font-sans);
color: var(--color-text);
background: var(--color-bg);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* 3. CSS Custom Properties (Design Tokens) */
:root {
--color-text: #111827;
--color-text-muted: #6b7280;
--color-bg: #ffffff;
--color-bg-subtle: #f9fafb;
--color-border: #e5e7eb;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-danger: #dc2626;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: "Fira Code", "Consolas", monospace;
}
/* 4. Typography */
h1 { font-size: 2.25rem; font-weight: 700; line-height: 1.2; }
h2 { font-size: 1.875rem; font-weight: 700; line-height: 1.3; }
h3 { font-size: 1.5rem; font-weight: 600; line-height: 1.4; }
/* 5. Utility classes that truly need to be global */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
Defining CSS variables in globals.css is powerful — your CSS Modules
can consume them without any imports:
/* Card.module.css — using global CSS variables */
.card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: 1.5rem;
}
.title {
color: var(--color-text);
}
.meta {
color: var(--color-text-muted);
}
Now changing --color-border in one place updates every component that
uses it. This is the closest CSS Modules gets to a theming system — and it's
surprisingly powerful.
6. The :global Escape Hatch
Sometimes you need to style something you don't control — a third-party component,
a dynamically added class, or a child element you can't reach with props.
The :global selector opts a specific rule out of scoping:
/* RichTextEditor.module.css */
.editor {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
}
/* Style the Tiptap editor's output — we don't control its class names */
.editor :global(.ProseMirror) {
outline: none;
min-height: 200px;
}
.editor :global(.ProseMirror p) {
margin-bottom: 0.75rem;
}
.editor :global(.ProseMirror h2) {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
/* Style based on a data attribute added dynamically */
.editor :global([data-placeholder]::before) {
content: attr(data-placeholder);
color: var(--color-text-muted);
pointer-events: none;
}
The .editor class is still scoped — so :global rules
inside it only apply to elements that are descendants of your scoped
.editor element. You get targeted global styles without polluting
the rest of the page.
7. CSS Modules with CSS Custom Properties for Theming
Here's a powerful pattern — use CSS custom properties to make a component theme-able from outside, without exposing internal class names:
/* ProgressBar.module.css */
.track {
width: 100%;
height: var(--progress-height, 8px); /* ← Configurable from outside */
background: var(--progress-bg, #e5e7eb);
border-radius: 9999px;
overflow: hidden;
}
.fill {
height: 100%;
background: var(--progress-color, var(--color-primary));
border-radius: 9999px;
transition: width 0.3s ease;
}
// ProgressBar.tsx
import styles from "./ProgressBar.module.css";
export default function ProgressBar({
value,
color,
height,
}: {
value: number; // 0–100
color?: string;
height?: string;
}) {
return (
<div
className={styles.track}
style={{
"--progress-color": color,
"--progress-height": height,
} as React.CSSProperties}
>
<div
className={styles.fill}
style={{ width: `${value}%` }}
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
}
// Usage — completely customisable without extra class names
<ProgressBar value={75} />
<ProgressBar value={40} color="#10b981" height="4px" />
<ProgressBar value={90} color="#f59e0b" height="12px" />
CSS custom properties set via inline style cascade into the component's
CSS Module rules. You get a clean component API that's fully style-able without
exposing internal class names or using prop drilling for every style option.
8. Animations in CSS Modules
CSS animations and keyframes work perfectly in CSS Modules. Keyframe names are
also scoped — so @keyframes fadeIn in one module won't conflict with
another:
/* Toast.module.css */
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.toast {
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
background: #1f2937;
color: white;
box-shadow: var(--shadow-lg);
animation: slideIn 0.3s ease forwards;
}
.toast.leaving {
animation: slideOut 0.3s ease forwards;
}
.success {
border-left: 4px solid #10b981;
}
.error {
border-left: 4px solid #ef4444;
}
9. File and Naming Conventions
There's no enforced convention but these patterns are widely used and work well:
| Approach | Structure | Best for |
|---|---|---|
| Colocated | Button/Button.tsx + Button/Button.module.css |
Most components — easy to find and delete together |
| Same directory | Button.tsx + Button.module.css in same folder |
Small projects or flat component structures |
| Styles folder | styles/Button.module.css |
When you want styles separate from components |
For class names inside the CSS file, use camelCase —
.primaryButton not .primary-button. Kebab-case works
too but requires bracket notation (styles["primary-button"]) instead
of dot notation (styles.primaryButton) in JavaScript.
10. TypeScript Support for CSS Modules
By default TypeScript treats imported CSS Modules as any — you don't
get autocomplete for class names. Add the typescript-plugin-css-modules
package to fix this:
npm install -D typescript-plugin-css-modules
// tsconfig.json
{
"compilerOptions": {
"plugins": [
{ "name": "typescript-plugin-css-modules" }
]
}
}
Now styles. shows autocomplete for every class defined in the
corresponding .module.css file. Typos in class names become
TypeScript errors instead of silent styling bugs.
11. Common Gotchas
-
Using kebab-case class names.
.my-classin CSS must be accessed asstyles["my-class"]in JavaScript — you lose dot notation. Stick to camelCase (.myClass) for cleaner code. -
Trying to style child components. CSS Modules scope styles to
the current component. You can't write
.card .button { ... }and expect it to style aButtoncomponent rendered inside aCard— theButton's class names are scoped to its own module. Use CSS variables or props to pass styling intent down. -
Forgetting that CSS Modules are compile-time only. You can't
dynamically construct class names with string concatenation —
styles[`size-${size}`]won't work unlesssizeSm,sizeMd, etc. are all statically present in your CSS file for the bundler to include. -
Importing a CSS Module in a Server Component. This works fine —
Server Components can import CSS Modules. The styles are extracted at build time
and injected as a
<link>tag in the HTML. No client-side JavaScript is needed for the styles to apply. -
Large CSS Module files. If a CSS Module file grows beyond
~100 lines, consider splitting it — one file per logical section of a complex
component, or extracting shared styles into a
shared.module.css.
Key Takeaways
- CSS Modules scope class names automatically — no naming conventions or runtime overhead required.
- Import as a JavaScript object and access class names as properties:
styles.className. - Use
clsxfor clean conditional class name composition. - The
composeskeyword lets one class inherit styles from another — like a mixin. - Global styles live in
globals.css— use CSS custom properties there to create design tokens available to all modules. - Use
:global()to style third-party components or dynamically added classes. - CSS custom properties via inline
styleprops make components themeable without exposing internal class names. - Use camelCase class names in CSS Modules to enable dot notation in JavaScript.
- CSS Modules work in Server Components — styles are extracted at build time, no JS needed.
Next up: Lesson 302 — Tailwind CSS Setup & Best Practices. You'll configure Tailwind v4 with Next.js, understand the utility-first philosophy, and learn the patterns that keep Tailwind codebases clean and maintainable at scale.