I spent an afternoon implementing dark mode on matthewswong.com — and then another afternoon fixing the flash of wrong theme on page load. The core problem: the server renders HTML without knowing the user's theme preference, the browser loads the HTML and paints it, then the JavaScript hydrates and switches to the correct theme. That moment between paint and hydration shows the wrong theme — a flash that looks unprofessional and can cause layout shift. The solution requires a blocking script that reads the preference before first paint. next-themes handles this correctly out of the box.
Dark mode seems simple: toggle a CSS class and swap colors. The complexity comes from three requirements working together: persist the user's choice across visits (localStorage), respect the OS-level system preference (prefers-color-scheme media query), and avoid a flash of the wrong theme on page load (the FOUC problem). The server-side rendering challenge is fundamental: the server doesn't know the client's theme preference, so the initial HTML is theme-agnostic. Without a blocking script that reads and applies the theme before paint, you always get a flash.
The CSS `prefers-color-scheme: dark` media query detects the user's OS-level dark mode preference. On macOS, this reflects System Preferences > Appearance. On Windows, it's Settings > Personalization > Colors. On iOS and Android, it's the system display mode. Without JavaScript, you can handle this in CSS alone: `@media (prefers-color-scheme: dark) { :root { --bg: #0a0a0a; } }`. With JavaScript (and localStorage for persistence), you can override the system preference, letting users choose light while their OS is in dark mode.
next-themes (github.com/pacocoursey/next-themes) is the most widely used dark mode solution for Next.js. It injects a small blocking script in the `<head>` that executes before any CSS or page content renders. The script reads the theme from localStorage, checks the prefers-color-scheme media query, and applies the correct class to the `<html>` element before the browser's first paint. This eliminates the flash. The library handles theme persistence, system preference detection, and React context for reading/setting the theme from any component.
Dark Mode — The Flash Problem and Fix:
────────────────────────────────────────────────────────────
WITHOUT next-themes (FOUC — Flash of Unstyled Content):
1. Server renders HTML (no theme class)
2. Browser paints: white background (default) ← FLASH visible
3. React hydrates → reads localStorage
4. Theme class added to <html>
5. CSS updates → switches to dark ← visible flash!
WITH next-themes (no flash):
1. Server renders HTML (no theme class)
2. next-themes injects blocking <script> in <head>:
(function() {
var theme = localStorage.getItem('theme') || 'system';
if (theme === 'dark' || (theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})()
3. Script runs SYNCHRONOUSLY before first paint
4. .dark class added before browser renders anything ← no flash!
5. Browser paints with dark styles already applied
6. React hydrates → no visual change
Theme priority:
Manual override (localStorage) > System preference (prefers-color-scheme)From my experience implementing dark mode on matthewswong.com: use CSS custom properties (variables) for all colors rather than Tailwind's dark: modifier classes. CSS variables update instantly when the html class changes — no re-render needed. Define your complete color palette as CSS variables in :root (light theme) and in .dark:root or [data-theme='dark'] (dark theme). Components use var(--bg-primary), var(--text-primary) etc., and the theme switch is instant with no JavaScript involved in the component. Tailwind's dark: classes work, but CSS variables are more performant for complex theming.
Setting up next-themes in the Next.js App Router requires wrapping the application in the ThemeProvider in a client component (since ThemeProvider uses React context, which is client-side). Create a Providers component that's a client component, wrap your children in ThemeProvider, and import this Providers component in your root layout. The ThemeProvider accepts: attribute ('class' for Tailwind, 'data-theme' for CSS variables), defaultTheme ('system' to follow OS), enableSystem (true), and storageKey (the localStorage key name).
The theme toggle reads the current theme with `useTheme()` from next-themes and cycles through light/dark/system. The useTheme hook returns: theme (the active setting: 'light', 'dark', or 'system'), resolvedTheme (the actual displayed theme: 'light' or 'dark', accounting for system preference), and setTheme (to change the preference). Use `resolvedTheme` to determine which icon to show (sun for light, moon for dark) — don't use `theme` because 'system' doesn't map to an icon directly. Always handle the unmounted state: on the server, neither `theme` nor `resolvedTheme` is defined, so render a fallback.
// npm install next-themes
// app/providers.tsx — client component wrapper
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class" // adds .dark class to <html>
defaultTheme="system" // follow OS preference by default
enableSystem={true} // detect prefers-color-scheme
disableTransitionOnChange // prevent flash during transition
storageKey="theme" // localStorage key
>
{children}
</ThemeProvider>
)
}
// app/layout.tsx — wrap everything in Providers
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
{/* suppressHydrationWarning prevents hydration mismatch warning */}
{/* next-themes adds/removes class before React hydrates */}
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
// tailwind.config.ts
export default {
darkMode: 'class', // .dark class toggles dark mode
content: ['./app/**/*.{ts,tsx}'],
}
// components/theme-toggle.tsx
'use client'
import { useTheme } from 'next-themes'
import { Moon, Sun, Monitor } from 'lucide-react'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Must wait for mount to avoid hydration mismatch
useEffect(() => setMounted(true), [])
if (!mounted) return <div className="w-9 h-9" /> // placeholder
return (
<button
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
>
{resolvedTheme === 'dark'
? <Sun className="w-5 h-5" />
: <Moon className="w-5 h-5" />}
</button>
)
}
// globals.css — CSS variables approach (faster than Tailwind dark: classes)
:root {
--bg-primary: #ffffff;
--text-primary: #0f172a;
--accent: #3b82f6;
}
.dark {
--bg-primary: #0a0a0a;
--text-primary: #f1f5f9;
--accent: #60a5fa;
}For Tailwind CSS, set `darkMode: 'class'` in tailwind.config.ts. This enables the `dark:` variant, which applies styles only when the `.dark` class is on the html element. Pair this with next-themes using `attribute='class'` — next-themes will add/remove the `.dark` class on html. Then use Tailwind utilities: `bg-white dark:bg-slate-900`, `text-slate-900 dark:text-slate-100`. For a three-theme setup (light, dark, custom), you'd need more configuration — CSS variables are easier than Tailwind classes for more than two themes.
A common mistake: rendering a sun/moon icon based on `theme` before the component is mounted, causing a hydration mismatch because the server renders a default (light) while the client renders the user's actual preference (dark). The fix: use a `mounted` state that starts as false and becomes true in useEffect. Before mounting, render a neutral placeholder (an empty span or a theme-toggle icon that doesn't indicate the current state). After mounting, render the actual theme indicator. next-themes exports a `useTheme` hook that warns about this misuse pattern in development.
The flow for a returning user: 1) Browser requests the page. 2) Server renders neutral HTML (no theme class yet). 3) HTML is sent to browser. 4) next-themes blocking script runs in <head>, reads localStorage, and adds .dark class to <html>. 5) Browser paints with dark styles already applied. 6) React hydrates — no visual change because the class was already set. Step 4 is the key: the blocking script must run synchronously before any paint, which is why next-themes puts it in a <script> tag (not a module script) in the document head. Module scripts and defer/async scripts don't block paint.
If the user changes their OS theme while your page is open (a common scenario on laptops where users enable dark mode at night via automatic scheduling), your app should update dynamically. next-themes handles this with a `window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...)` listener. When the preference changes, if the user hasn't set a manual override (their preference is 'system'), next-themes updates the resolved theme and applies the new class. If the user has a manual override (they set 'light' explicitly), the OS change is ignored. This behavior is correct: the user's explicit choice beats the system default.