🎨 Chapter 21: Styling
Every CSS approach Next.js supports, which ones fight React Server Components, and how to pick.
📖 Plain English Explanation
Next.js doesn't have a styling opinion. Tailwind, CSS Modules, plain CSS, Sass, and CSS-in-JS all work.
But the App Router did change the calculus, and it's worth understanding why before you pick.
Server Components render on the server and ship no JavaScript. Runtime CSS-in-JS libraries — styled-components, Emotion — generate styles by executing JavaScript in the browser. Those two facts are in direct conflict. Runtime CSS-in-JS in the App Router means every styled component becomes a Client Component, which means you've opted your tree back into the client bundle to change a colour.
That's why the ecosystem consolidated hard around Tailwind and CSS Modules since the App Router shipped. Both produce static CSS at build time. Both work perfectly with Server Components. Neither costs you a byte of JavaScript.
This chapter covers all the options honestly, but the recommendation is straightforward: Tailwind for most projects, CSS Modules when you want scoped stylesheets, and runtime CSS-in-JS only if you're already committed to it.
🌊 Tailwind CSS
create-next-app --tailwind sets up Tailwind v4, which is configured in CSS rather than a JavaScript config file.
/* app/globals.css */
@import 'tailwindcss';
@theme {
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a8a;
--font-sans: var(--font-inter), system-ui, sans-serif;
--font-mono: var(--font-jetbrains), ui-monospace, monospace;
--radius-card: 0.75rem;
}
// app/layout.tsx
import './globals.css'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Your theme values become utilities automatically: bg-brand-500, text-brand-900, rounded-card.
⚠️ Tailwind v4 vs v3
Tailwind v4 removed
tailwind.config.jsin favour of the@themeblock, and replaced the three@tailwinddirectives with one@import.css/* ❌ Tailwind v3 */ @tailwind base; @tailwind components; @tailwind utilities; /* ✅ Tailwind v4 */ @import 'tailwindcss';If you're on v3, Next.js still supports it — but a v3 tutorial's setup steps won't work on a fresh
create-next-app.
Why it fits the App Router
// A Server Component. Zero JavaScript shipped. Fully styled.
export default async function ProductCard({ product }: { product: Product }) {
return (
<div className="rounded-card border border-slate-200 p-4 transition-shadow hover:shadow-md">
<h3 className="text-lg font-semibold text-slate-900">{product.name}</h3>
<p className="mt-1 text-sm text-slate-600">{product.description}</p>
<span className="mt-3 block text-xl font-bold text-brand-500">
${(product.priceInCents / 100).toFixed(2)}
</span>
</div>
)
}
Classes are strings. Strings are static. Nothing runs in the browser.
Managing conditional classes
String concatenation gets ugly fast. Use clsx plus tailwind-merge:
npm install clsx tailwind-merge
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// lib/utils.js
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
// components/ui/Button.tsx
import { cn } from '@/lib/utils'
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
export function Button({
variant = 'primary',
size = 'md',
className,
...props
}: Props) {
return (
<button
className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
'disabled:pointer-events-none disabled:opacity-50',
{
'bg-brand-500 text-white hover:bg-brand-600': variant === 'primary',
'bg-slate-100 text-slate-900 hover:bg-slate-200': variant === 'secondary',
'hover:bg-slate-100': variant === 'ghost',
},
{
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4': size === 'md',
'h-12 px-6 text-lg': size === 'lg',
},
className // caller overrides win, thanks to twMerge
)}
{...props}
/>
)
}
twMerge is the important half. Without it, cn('px-4', 'px-6') emits both and CSS source order decides — which is unpredictable. With it, px-6 wins as you'd expect.
📦 CSS Modules
Locally scoped CSS with no naming conventions to maintain.
/* app/components/Card.module.css */
.card {
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1rem;
transition: box-shadow 150ms;
}
.card:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 0.08);
}
.title {
font-size: 1.125rem;
font-weight: 600;
}
.featured {
border-color: var(--brand);
background: var(--brand-50);
}
// app/components/Card.tsx
import styles from './Card.module.css'
export function Card({
title,
featured,
}: {
title: string
featured?: boolean
}) {
return (
<div className={`${styles.card} ${featured ? styles.featured : ''}`}>
<h3 className={styles.title}>{title}</h3>
</div>
)
}
// app/components/Card.js
import styles from './Card.module.css'
export function Card({ title, featured }) {
return (
<div className={`${styles.card} ${featured ? styles.featured : ''}`}>
<h3 className={styles.title}>{title}</h3>
</div>
)
}
Class names are hashed at build time — .card becomes .Card_card__x7f2a — so collisions are impossible.
Works in Server Components. Zero runtime cost. The main downsides are file-count sprawl and the awkwardness of composing conditional classes compared to Tailwind.
🌐 Global CSS
Imported once, in the root layout:
/* app/globals.css */
@import 'tailwindcss';
:root {
--border: #e2e8f0;
--brand: #3b82f6;
--brand-50: #eff6ff;
}
@media (prefers-color-scheme: dark) {
:root {
--border: #334155;
--brand-50: #172554;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
-webkit-font-smoothing: antialiased;
}
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Global CSS can only be imported in the root layout. Importing it in a component throws.
🧵 Sass
npm install -D sass
Rename to .scss and it works — plus .module.scss for scoped Sass.
// app/components/Card.module.scss
@use 'sass:color';
$brand: #3b82f6;
.card {
border: 1px solid color.adjust($brand, $lightness: 40%);
padding: 1rem;
&:hover {
border-color: $brand;
}
.title {
font-weight: 600;
}
}
// next.config.ts
const nextConfig: NextConfig = {
sassOptions: {
additionalData: `@use "@/styles/variables" as *;`,
},
}
⚠️ Changed in Next.js 16
sass-loaderwas bumped to v16, which uses the modern Sass API. Turbopack supports importing Sass fromnode_modules, but does not support the legacy tilde prefix:scss/* ❌ Webpack-era syntax */ @import '~bootstrap/dist/css/bootstrap.min.css'; /* ✅ Turbopack */ @import 'bootstrap/dist/css/bootstrap.min.css';If changing the imports isn't possible, alias it:
tsturbopack: { resolveAlias: { '~*': '*' } }
Honest assessment: most of what Sass offered — variables, nesting — is now native CSS. Reach for it when you're maintaining an existing Sass codebase, not for a greenfield project.
💅 CSS-in-JS
The awkward one.
Runtime libraries need 'use client'
styled-components, Emotion, and similar generate styles by running JavaScript in the browser. They cannot work in Server Components.
// components/StyledButton.tsx
'use client' // ← required, and it costs you
import styled from 'styled-components'
export const StyledButton = styled.button`
padding: 0.5rem 1rem;
background: ${(props) => props.theme.brand};
border-radius: 0.375rem;
`
Every component using it becomes a Client Component. If your design system is built this way, your entire UI ships to the browser — which is the opposite of what the App Router is for.
Getting styled-components working also requires a registry to collect styles during SSR:
// lib/StyledComponentsRegistry.tsx
'use client'
import { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
export function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode
}) {
const [sheet] = useState(() => new ServerStyleSheet())
useServerInsertedHTML(() => {
const styles = sheet.getStyleElement()
sheet.instance.clearTag()
return <>{styles}</>
})
if (typeof window !== 'undefined') return <>{children}</>
return (
<StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>
)
}
Doable, but it's friction you're choosing to accept.
Zero-runtime alternatives
If you like the CSS-in-JS authoring model, use a library that compiles to static CSS at build time:
| Library | Notes |
|---|---|
| Panda CSS | Type-safe tokens, compiles to atomic CSS, works in Server Components |
| vanilla-extract | TypeScript stylesheets compiled at build time |
| StyleX | Meta's atomic CSS compiler |
| Linaria | Zero-runtime styled-components syntax |
These get you typed styles and colocation without the client-boundary cost.
⚙️ CSS delivery options
Two next.config.ts options worth knowing:
// next.config.ts
const nextConfig: NextConfig = {
// Inline small CSS into the HTML instead of a separate request
inlineCss: true,
// Control how CSS files are split
cssChunking: 'strict', // 'loose' | 'strict' | false
}
inlineCss removes a render-blocking round-trip for small stylesheets — often a measurable First Contentful Paint win on a marketing page. Less useful when your CSS is large, since inlining it prevents browser caching.
cssChunking: 'strict' guarantees CSS load order matches import order, at the cost of more, smaller files. Worth it if you've ever debugged a specificity bug caused by chunk ordering.
🌗 Dark mode
The tricky part isn't the CSS — it's avoiding a flash of the wrong theme before hydration.
CSS-only, following the OS
/* app/globals.css */
@import 'tailwindcss';
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--fg: #f1f5f9;
}
}
No flash, no JavaScript, but no user override.
With a toggle, no flash
Read the preference from a cookie in the root layout — on the server — so the first HTML already has the right class:
// app/layout.tsx
import { cookies } from 'next/headers'
import './globals.css'
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return (
<html lang="en" className={theme === 'dark' ? 'dark' : undefined}>
<body>{children}</body>
</html>
)
}
// app/layout.js
import { cookies } from 'next/headers'
import './globals.css'
export default async function RootLayout({ children }) {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return (
<html lang="en" className={theme === 'dark' ? 'dark' : undefined}>
<body>{children}</body>
</html>
)
}
// app/actions/theme.ts
'use server'
import { cookies } from 'next/headers'
import { refresh } from 'next/cache'
export async function setTheme(theme: 'light' | 'dark') {
;(await cookies()).set('theme', theme, {
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
})
refresh()
}
// app/ThemeToggle.tsx
'use client'
import { useTransition } from 'react'
import { setTheme } from './actions/theme'
export function ThemeToggle({ current }: { current: 'light' | 'dark' }) {
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() =>
startTransition(() => setTheme(current === 'dark' ? 'light' : 'dark'))
}
>
{current === 'dark' ? '☀️' : '🌙'}
</button>
)
}
Cookie-based beats localStorage here precisely because the server can read it. localStorage isn't available during SSR, which is exactly what causes the flash.
Note: this makes the root layout read a cookie. With Cache Components (Chapter 16), keep the read shallow — or accept that the root layout is request-dependent, which is usually fine for a theme.
📱 Responsive design
Tailwind's breakpoints are mobile-first:
<div className="
grid grid-cols-1 gap-4
sm:grid-cols-2
lg:grid-cols-3
xl:grid-cols-4
">
Unprefixed = all sizes. Prefixed = that breakpoint and up. Write the mobile layout first, then add larger breakpoints — the reverse produces a cascade of overrides.
Container queries, for components that should respond to their container rather than the viewport:
<div className="@container">
<div className="flex flex-col @md:flex-row">
<Image … />
<div>…</div>
</div>
</div>
Genuinely better than viewport breakpoints for reusable components — the same card can sit in a sidebar or a full-width section and lay itself out correctly.
⚠️ Common Pitfalls
1. Runtime CSS-in-JS in the App Router
Every styled component becomes a Client Component. If your design system uses styled-components, your entire UI ships to the browser.
2. Dynamic class names Tailwind can't see
// ❌ Tailwind scans source text — it never sees "text-red-500"
<div className={`text-${color}-500`} />
// ✅ full class names in the source
const colors = {
red: 'text-red-500',
blue: 'text-blue-500',
} as const
<div className={colors[color]} />
This produces a class that exists in your JSX but not in your CSS — and it works locally if some other component happened to use it. Classic "works on my machine".
3. Conflicting Tailwind classes without twMerge
// ❌ both emitted, source order decides
<Button className="px-8" /> // component also has px-4
// ✅ cn() with twMerge — px-8 wins
4. Global CSS outside the root layout
Error: Global CSS cannot be imported from files other than your Custom <App>
Only the root layout. Use CSS Modules elsewhere.
5. Flash of unstyled/wrong theme
Reading theme from localStorage in a useEffect means the first paint is wrong. Use a cookie the server can read.
6. Tilde imports in Sass with Turbopack
@import '~bootstrap/…'; // ❌
@import 'bootstrap/…'; // ✅
7. Tailwind v3 setup on a v4 project
@tailwind base; /* ❌ v3 */
@import 'tailwindcss'; /* ✅ v4 */
8. Long class strings as a reason to abandon Tailwind
The fix is component extraction, not a different CSS strategy:
// ❌ repeated 40 times across the codebase
<button className="inline-flex items-center rounded-md bg-blue-500 px-4 py-2 …">
// ✅ once
<Button variant="primary">
🎯 When & Why to Use
Tailwind when:
✅ You want speed and consistency without naming things
✅ Server Components matter (they do)
✅ The team is comfortable with utility classes
→ the default recommendation for new projects
CSS Modules when:
✅ You prefer writing real CSS in real stylesheets
✅ Complex animations, keyframes, or unusual selectors
✅ Migrating an existing CSS codebase
→ works fine alongside Tailwind
Plain global CSS when:
✅ CSS variables, resets, base typography
→ almost always in addition to something else
Sass when:
✅ You already have a Sass codebase
❌ New projects — native CSS covers most of it now
Zero-runtime CSS-in-JS (Panda, vanilla-extract) when:
✅ You want typed styles colocated with components
✅ Without the Client Component cost
Runtime CSS-in-JS (styled-components, Emotion) when:
⚠️ You're already committed and migration is expensive
❌ Not for new App Router projects
Most real projects: Tailwind + a globals.css for variables and resets, with CSS Modules for the occasional component that wants a real stylesheet.
🏋️ Mini Practice Problems
Problem 1: Why is the class missing?
Works in dev, the badge is unstyled in production:
export function Badge({ color }: { color: string }) {
return <span className={`bg-${color}-100 text-${color}-800 px-2 rounded`}>New</span>
}
Explain the mechanism and give two fixes.
Problem 2: Fix the flash
'use client'
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
useEffect(() => {
setTheme(localStorage.getItem('theme') ?? 'light')
}, [])
return <div className={theme}>{children}</div>
}
Why does the page flash white before going dark? Rewrite it with no flash.
Problem 3: Audit the cost
A design system built entirely with styled-components is used on a marketing page that's otherwise all Server Components. What actually ships to the browser, and what would you change?
Problem 4: Build it
A <Card> component that:
- Has
default,outlined, andelevatedvariants - Accepts a
classNamethat correctly overrides internal classes - Works in a Server Component
- Responds to its container width, not the viewport
- Supports dark mode with no flash
💼 Interview Notes
Common Questions
Q: Why did runtime CSS-in-JS fall out of favour with the App Router?
It generates styles by executing JavaScript in the browser, which requires 'use client'. Every styled component becomes a Client Component, pulling your UI into the browser bundle — the opposite of what Server Components are for.
Q: Why does Tailwind work well with Server Components? Classes are static strings resolved to CSS at build time. Nothing executes at runtime, so a fully-styled component can ship zero JavaScript.
Q: What does tailwind-merge solve?
Conflicting utilities. cn('px-4', 'px-6') would otherwise emit both and let CSS source order decide. twMerge resolves the conflict so the last one wins predictably — which is what makes className overrides work on component APIs.
Q: Why does a dynamic Tailwind class fail in production?
Tailwind scans source files as text for complete class names. `bg-${color}-500` never appears literally, so the class isn't generated. Map to full class names instead.
Q: How do you prevent a flash of the wrong theme?
Store the preference in a cookie and read it on the server in the root layout, so the very first HTML has the correct class. localStorage can't be read during SSR, which is precisely what causes the flash.
Q: What's the difference between global CSS and CSS Modules in the App Router? Global CSS can only be imported in the root layout and applies everywhere. CSS Modules can be imported anywhere and have their class names hashed for local scoping.
Q: What changed for Sass in Next.js 16?
sass-loader moved to v16 with the modern Sass API, and Turbopack dropped support for the legacy tilde (~) import prefix used for node_modules.
🏢 Asked at Companies
- Vercel: "Your team wants styled-components in a Next.js 16 app. What's your recommendation and why?"
- Shopify: "Design a Button component API where callers can override internal styles safely."
- Linear: "How would you implement theming with no flash and instant switching?"
- Stripe: "Utility classes vs. semantic class names. Argue both sides, then pick."
📊 Visual Memory Aid
THE SERVER COMPONENT TEST
Does it produce CSS at BUILD time?
├── Yes → works in Server Components ✅
│ Tailwind, CSS Modules, global CSS, Sass,
│ Panda, vanilla-extract, StyleX
└── No → needs 'use client' ⚠️
styled-components, Emotion (runtime)
WHAT SHIPS
Tailwind / CSS Modules
HTML + CSS file 0 KB JS ✅
styled-components
HTML + JS runtime + JS for every styled component
~15KB+ JS, plus the boundary cost
THE cn() PATTERN
cn('px-4 bg-blue-500', isLarge && 'px-8', className)
│ │ │
│ │ └─ caller override
│ └─ conditional
└─ base
twMerge resolves px-4 vs px-8 → px-8 wins ✅
Without it → both emitted, source order decides ❌
DARK MODE WITHOUT A FLASH
❌ useEffect → localStorage → setState
paint 1: light 👀 flash
paint 2: dark
✅ cookies() in the root layout (server)
paint 1: dark ✅ correct immediately
🎯 Key Takeaways
- Build-time CSS works with Server Components; runtime CSS-in-JS doesn't. That single fact explains the whole ecosystem shift toward Tailwind and CSS Modules.
- Tailwind v4 is configured in CSS via
@import 'tailwindcss'and@theme—tailwind.config.jsand the three@tailwinddirectives are v3. - Use
cn()—clsxplustailwind-merge. WithouttwMerge, conflicting utilities resolve by source order andclassNameoverrides break unpredictably. - Tailwind can't see dynamic class names. It scans source text for literal class names, so
`bg-${color}-500`produces nothing. Map to complete strings. - Read the theme from a cookie on the server to eliminate the flash.
localStoragedoesn't exist during SSR, which is exactly why theuseEffectapproach flickers.
Next Chapter: Performance Optimization →
Practice: Build a Button and Card with variants using cn(), prove className overrides work, then add cookie-based dark mode and confirm with a hard refresh that there's no flash.