Dark Mode in Next.js with Tailwind CSS: The Complete Implementation
Dark mode done right means no flash of wrong theme, correct system preference detection, and persistent user preference — all without layout shift. Here's how to build it.
The Three Requirements
Good dark mode implementation needs to satisfy three requirements simultaneously:
- Respect system preference (
prefers-color-scheme: dark) on first visit - Persist user choice when they manually switch (stored in localStorage or a cookie)
- No flash of wrong theme — the theme must be applied before the first paint
These requirements conflict. System preference and user preference are client-side concepts. But no flash requires the theme to be applied server-side or via a synchronous script before React hydrates. Getting all three right requires careful architecture.
The Approach: Class-Based Theming
Tailwind's dark mode with class strategy (as opposed to media) gives you explicit control:
// tailwind.config.ts
export default {
darkMode: 'class', // Enable class-based dark mode
// ...
};
With this setting, adding the dark class to <html> activates all dark: variants. Your theme switch is a single DOM operation.
Preventing Flash with a Blocking Script
The flash happens because Next.js renders HTML on the server (which doesn't know the user's preference) and sends it to the browser, which then runs JavaScript that applies the correct theme — causing a visible repaint.
The fix: a synchronous, inline <script> in <head> that runs before any rendering:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored ?? (prefersDark ? 'dark' : 'light');
if (theme === 'dark') document.documentElement.classList.add('dark');
} catch (e) {}
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
The suppressHydrationWarning on <html> is necessary because the class will differ between server render (no dark class) and client (potentially with dark class). Without it, React logs a hydration mismatch warning.
The try/catch wraps the localStorage access — it throws in private browsing on some browsers.
The Theme Context
// lib/theme-context.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
const ThemeContext = createContext<{
theme: Theme;
setTheme: (theme: Theme) => void;
}>({ theme: 'system', setTheme: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) setThemeState(stored);
}, []);
function setTheme(newTheme: Theme) {
setThemeState(newTheme);
if (newTheme === 'system') {
localStorage.removeItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
} else {
localStorage.setItem('theme', newTheme);
document.documentElement.classList.toggle('dark', newTheme === 'dark');
}
}
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
// app/layout.tsx — wrap children in provider
import { ThemeProvider } from '@/lib/theme-context';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* ... blocking script from above ... */}
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
The Toggle Component
// components/ThemeToggle.tsx
'use client';
import { useTheme } from '@/lib/theme-context';
import { Sun, Moon, Monitor } from 'lucide-react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<div className="flex gap-1 rounded-lg bg-surface-elevated p-1">
{[
{ value: 'light', icon: Sun },
{ value: 'system', icon: Monitor },
{ value: 'dark', icon: Moon },
].map(({ value, icon: Icon }) => (
<button
key={value}
onClick={() => setTheme(value as 'light' | 'system' | 'dark')}
className={`rounded-md p-1.5 transition-colors ${
theme === value
? 'bg-white text-gray-900 shadow dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
aria-label={`Switch to ${value} mode`}
>
<Icon className="h-4 w-4" />
</button>
))}
</div>
);
}
Using Dark Mode in Components
With Tailwind's dark: variants:
export function Card({ title, description }: { title: string; description: string }) {
return (
<div className="rounded-xl bg-white p-6 shadow dark:bg-gray-800">
<h3 className="text-gray-900 dark:text-gray-100">{title}</h3>
<p className="text-gray-600 dark:text-gray-400">{description}</p>
</div>
);
}
If you've set up CSS custom properties as your token layer (as described in our design tokens guide), the dark mode application is even simpler — the dark class on <html> swaps all your token values automatically, with no dark: prefixes needed in component classes.
System Preference Changes
Handle the edge case where a user's system preference changes while they have the "system" theme selected:
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (theme === 'system') {
document.documentElement.classList.toggle('dark', e.matches);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
With this, the UI responds in real-time when a user switches their OS theme — matching the behavior users expect from well-built native apps.
Continue reading
Related articles
Rate Limiting in Next.js: Protecting Your API Routes
How to implement production-grade rate limiting in Next.js — with Middleware-level protection, per-user limits, and distributed rate limiting using Upstash Redis.
EngineeringNext.js Parallel Routes and Intercepting Routes: A Complete Guide
Parallel routes and intercepting routes are among the most powerful App Router primitives. This guide explains what they do, when to use them, and how to avoid the common pitfalls.
EngineeringVercel vs Netlify vs AWS Amplify for Next.js in 2026
A practical comparison of the three most common Next.js hosting platforms — Vercel, Netlify, and AWS Amplify — with real cost and capability trade-offs.
Stay informed
Get our monthly deep dives.
Engineering, design, and growth insights — once a month. No spam.
Browse all resources