Eliminating Theme Flash in Next.js Without Sacrificing Caching or Streaming

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.

April 29, 2026

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(),
          }}
        />
        <link rel="preconnect" href="https://images.unsplash.com" />
        <link rel="dns-prefetch" href="https://images.unsplash.com" />
      </head>
      <body className={`${geistSans.variable} ${geistMono.variable}`}>
        {children}
      </body>
    </html>
  );
}