Dark mode sounds simple until you implement it. Then you discover the flash.
On first load, before JavaScript runs, your page renders with the default theme. Then the theme switcher kicks in. For a fraction of a second — sometimes longer on slow connections — users see the wrong theme before it corrects itself.
This is the flash of unstyled content (FOUC) applied to theming, and it's one of the more annoying UX problems to solve correctly in Next.js App Router.
Here's the approach that works, which I used building the theming system for — 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 localStorage persistence
- 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.
SOCIAL SHARE CARD GENERATOR