Storing a user’s theme preference in localStorage is convenient—no server round-trips, no cookie consent headaches. But it creates a classic problem: on the first paint, the page always shows the default theme, then quickly swaps to the saved one once JavaScript loads. That jarring flicker is known as a flash of incorrect theme. A common alternative is to store the theme in a cookie, read it server-side, and inject the correct class or data-attribute directly into the HTML. This eliminates the flash, but it comes with painful trade-offs: Caching suffers – The server can no longer return a static, cacheable response because the HTML depends on a per-user cookie. CDN caching and stale-while-revalidate effectively break. Streaming breaks – If you’re using Next.js App Router’s streaming or Suspense, you now need to await cookies() at the very root layout, blocking the entire stream and defeating the purpose. So we’re caught between a flicker and a performance cliff.
We can keep the theme in localStorage and still avoid the flash by injecting a tiny, synchronous script into the
that reads the saved preference before the browser paints anything. Because it’s blocking, the correct theme is applied immediately without any visible transition.Here’s a helper that generates that script:
// utils/themer.ts
export const THEME_STORAGE_KEY = "theme";
export function getThemeHeadBlockingInlineScript(): string {
const key = JSON.stringify(THEME_STORAGE_KEY);
return `(function(){var k=${key};var el=document.documentElement;var stored;try{stored=localStorage.getItem(k);}catch(e){}var pref=stored||"system";if(pref!=="light"&&pref!=="dark"&&pref!=="system"){pref="system";}var resolved=pref==="system"?(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"):pref;if(resolved==="light"||resolved==="dark"){el.setAttribute("data-theme",resolved);el.style.colorScheme=resolved;}})();`;
}
Place this script before any stylesheets or content in your root layout:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning data-scroll-behavior="smooth">
<head>
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: getThemeHeadBlockingInlineScript(),
}}
/>
);
}