Building a Dark Mode System in Next.js App Router - Without Layout Flash
Why the Flash Happens
Next.js App Router renders on the server by default. The server doesn't know the user's theme preference - that's stored in localStorage or a cookie on the client. So the server renders with the default theme, sends that HTML to the browser, and then JavaScript runs and corrects the theme. The gap between HTML arriving and JavaScript running equals the flash.
The Solution - Inline Script Before React Hydration
The key insight: to prevent the flash, you need to apply the theme before React hydrates. This means a synchronous inline script in the <head> that reads the preference and applies it immediately.
// app/layout.js
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
var prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
Why suppressHydrationWarning? React will complain that the server-rendered HTML doesn't match the client (because we've added a dark class that the server didn't know about). This prop tells React to ignore that mismatch on the html element specifically.
Why try/catch? localStorage can throw in certain browser environments (private mode, security policies). Wrapping prevents a JavaScript error from breaking the page.
The Theme Context
// contexts/ThemeContext.js
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext({
theme: 'system',
setTheme: () => {},
resolvedTheme: 'light',
});
export function ThemeProvider({ children }) {
const [theme, setThemeState] = useState('system');
const [resolvedTheme, setResolvedTheme] = useState('light');
useEffect(() => {
// Read stored preference on mount
const stored = localStorage.getItem('theme') ?? 'system';
setThemeState(stored);
}, []);
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
setResolvedTheme('dark');
localStorage.setItem('theme', 'dark');
} else if (theme === 'light') {
root.classList.remove('dark');
setResolvedTheme('light');
localStorage.setItem('theme', 'light');
} else {
// System preference
localStorage.removeItem('theme');
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
if (prefersDark) {
root.classList.add('dark');
setResolvedTheme('dark');
} else {
root.classList.remove('dark');
setResolvedTheme('light');
}
}
}, [theme]);
// Listen for system preference changes
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => {
setResolvedTheme(e.matches ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', e.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [theme]);
const setTheme = (newTheme) => setThemeState(newTheme);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
The Toggle Component
// components/ThemeToggle.jsx
'use client';
import { useTheme } from '@/contexts/ThemeContext';
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
return (
<div className="flex items-center gap-2">
<button
onClick={() => setTheme('light')}
className={`p-2 rounded-lg transition-colors ${
theme === 'light'
? 'bg-neutral-200 dark:bg-neutral-700'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
aria-label="Light mode"
>
☀️
</button>
<button
onClick={() => setTheme('system')}
className={`p-2 rounded-lg transition-colors ${
theme === 'system'
? 'bg-neutral-200 dark:bg-neutral-700'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
aria-label="System preference"
>
💻
</button>
<button
onClick={() => setTheme('dark')}
className={`p-2 rounded-lg transition-colors ${
theme === 'dark'
? 'bg-neutral-200 dark:bg-neutral-700'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
aria-label="Dark mode"
>
🌙
</button>
</div>
);
}
CSS Variables for Theming
Using CSS variables with Tailwind's dark mode makes theme-aware colors straightforward:
/* globals.css */
:root {
--background: 255 255 255;
--foreground: 15 15 15;
--card: 248 248 248;
--border: 226 226 226;
--muted: 115 115 115;
}
.dark {
--background: 10 10 10;
--foreground: 245 245 245;
--card: 24 24 24;
--border: 38 38 38;
--muted: 163 163 163;
}
// tailwind.config.js
module.exports = {
darkMode: 'class',
theme: {
extend: {
colors: {
background: 'rgb(var(--background) / <alpha-value>)',
foreground: 'rgb(var(--foreground) / <alpha-value>)',
card: 'rgb(var(--card) / <alpha-value>)',
border: 'rgb(var(--border) / <alpha-value>)',
muted: 'rgb(var(--muted) / <alpha-value>)',
},
},
},
};
Now you can use bg-background, text-foreground, border-border anywhere in your components and they switch automatically.
Common Pitfalls
- Forgetting
suppressHydrationWarning: React will throw hydration warnings in development. Add it to the<html>element specifically - not globally. - Using
useEffectto readlocalStorageon mount: This still causes a flash, just a shorter one. The inline script approach is necessary to eliminate the flash entirely. - Not handling the system preference case: If the user has "system" preference and changes their OS theme while on your site, nothing updates unless you listen for
prefers-color-schemechanges. - Images that don't adapt: Dark and light mode images need separate handling. Either use CSS filters (
filter: invert(1)on dark backgrounds) or conditionally render different image sources usingresolvedTheme.
Testing
// Verify no flash on load
// 1. Set theme to dark in localStorage
// 2. Hard refresh - should load dark immediately
// 3. Clear localStorage - should follow system preference
// 4. Toggle OS dark mode - should update without refresh (system mode)
The inline script approach eliminates the flash across all browsers I've tested. The trade-off is a small inline script in every page's <head> - negligible performance impact for the user experience improvement it provides.
What I Built This On
This theming system is running in production on pixova.io - the dark/light toggle in the navigation uses exactly this approach. No flash, system preference respected, instant switching. Questions about specific edge cases? Comments open.
Handling Server Components With Theme
One tricky area in App Router: server components don't have access to the client's theme preference. This affects things like server-rendered images or content that should vary by theme.
Option 1 - CSS-only solution (preferred): Use CSS to show/hide variants based on the .dark class:
/* Show light version by default, dark version when .dark is active */
.logo-light {
display: block;
}
.logo-dark {
display: none;
}
.dark .logo-light {
display: none;
}
.dark .logo-dark {
display: block;
}
This requires no JavaScript and works instantly since the .dark class is already applied by the inline script.
Option 2 - Client component wrapper: Wrap theme-sensitive server components in a client component that reads resolvedTheme:
'use client';
import { useTheme } from '@/contexts/ThemeContext';
export function ThemedImage({ lightSrc, darkSrc, alt }) {
const { resolvedTheme } = useTheme();
return (
<img
src={resolvedTheme === 'dark' ? darkSrc : lightSrc}
alt={alt}
/>
);
}
The downside: this introduces a client boundary just for the image. The CSS approach is cleaner for most cases.
Cookie-Based Alternative (For SSR Accuracy)
If you need the server to know the theme (for server-rendered charts, personalized content, or avoiding any flash at all), store the preference in a cookie rather than localStorage.
// middleware.js - read theme cookie, add to response
import { NextResponse } from 'next/server';
export function middleware(request) {
const theme = request.cookies.get('theme')?.value ?? 'system';
const response = NextResponse.next();
// Pass theme as a header the layout can read
response.headers.set('x-theme', theme);
return response;
}
// app/layout.js - read theme from headers for SSR
import { headers } from 'next/headers';
export default function RootLayout({ children }) {
const headersList = headers();
const theme = headersList.get('x-theme') ?? 'system';
const isDark = theme === 'dark';
return (
<html lang="en" className={isDark ? 'dark' : ''} suppressHydrationWarning>
...
</html>
);
}
This eliminates the flash entirely - even before JavaScript runs - because the server knows the theme. The trade-off: requires a middleware layer and adds a cookie to every request. For most use cases, the inline script approach is sufficient and simpler. The cookie approach is worth implementing if you have server-rendered content that needs to match the theme exactly on first load.
Summary
The flash-free dark mode stack in Next.js App Router:
- Inline script in
<head>reads preference and applies class before hydration suppressHydrationWarningon<html>to suppress React's mismatch warning- Theme context manages state and
localStoragepersistence - CSS variables handle color switching
- System preference listener updates in real-time for "system" mode
The inline script is the non-obvious piece that most implementations miss. Without it, no amount of context optimization eliminates the flash.
Comments
No comments yet. Start the discussion.