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.
Nextcraft Engineering Team
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
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.