Dev Logs
/Next.js/ Chapter 21: Styling
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
    • Plain English Explanation
    • Tailwind CSS
    • Why it fits the App Router
    • Managing conditional classes
    • CSS Modules
    • Global CSS
    • Sass
    • CSS-in-JS
    • Runtime libraries need 'use client'
    • Zero-runtime alternatives
    • CSS delivery options
    • Dark mode
    • CSS-only, following the OS
    • With a toggle, no flash
    • Responsive design
    • Common Pitfalls
    • . Runtime CSS-in-JS in the App Router
    • . Dynamic class names Tailwind can't see
    • . Conflicting Tailwind classes without twMerge
    • . Global CSS outside the root layout
    • . Flash of unstyled/wrong theme
    • . Tilde imports in Sass with Turbopack
    • . Tailwind v3 setup on a v4 project
    • . Long class strings as a reason to abandon Tailwind
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Why is the class missing?
    • Problem 2: Fix the flash
    • Problem 3: Audit the cost
    • Problem 4: Build it
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🎨 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.

css
/* 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;
}
tsx
// 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.js in favour of the @theme block, and replaced the three @tailwind directives 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

tsx
// 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:

bash
npm install clsx tailwind-merge
ts
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
js
// lib/utils.js
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs) {
  return twMerge(clsx(inputs))
}
tsx
// 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.

css
/* 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);
}
tsx
// 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>
  )
}
jsx
// 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:

css
/* 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

bash
npm install -D sass

Rename to .scss and it works — plus .module.scss for scoped Sass.

scss
// 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;
  }
}
ts
// next.config.ts
const nextConfig: NextConfig = {
  sassOptions: {
    additionalData: `@use "@/styles/variables" as *;`,
  },
}

⚠️ Changed in Next.js 16

sass-loader was bumped to v16, which uses the modern Sass API. Turbopack supports importing Sass from node_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:

ts
turbopack: { 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.

tsx
// 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:

tsx
// 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:

LibraryNotes
Panda CSSType-safe tokens, compiles to atomic CSS, works in Server Components
vanilla-extractTypeScript stylesheets compiled at build time
StyleXMeta's atomic CSS compiler
LinariaZero-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:

ts
// 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

css
/* 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:

tsx
// 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>
  )
}
jsx
// 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>
  )
}
ts
// 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()
}
tsx
// 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:

tsx
<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:

tsx
<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

tsx
// ❌ 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

tsx
// ❌ 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

scss
@import '~bootstrap/…';   // ❌
@import 'bootstrap/…';    // ✅

7. Tailwind v3 setup on a v4 project

css
@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:

tsx
// ❌ 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:

tsx
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

tsx
'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, and elevated variants
  • Accepts a className that 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

  1. 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.
  2. Tailwind v4 is configured in CSS via @import 'tailwindcss' and @theme — tailwind.config.js and the three @tailwind directives are v3.
  3. Use cn() — clsx plus tailwind-merge. Without twMerge, conflicting utilities resolve by source order and className overrides break unpredictably.
  4. 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.
  5. Read the theme from a cookie on the server to eliminate the flash. localStorage doesn't exist during SSR, which is exactly why the useEffect approach 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.


PreviousChapter 20: Images & FontsNextChapter 22: Performance Optimization

Open source, free forever. Built by iammhador.

Contribute on GitHub