Next.js - Ah, FOUC...

10 minute readAug 21, 2025Sep 13, 2025webdev, nextjs

The problem

What is "FOUC"? FOUC is what you say when you run into this issue FOUC is an issue that happens when the page content loads before its styles. It causes a split-second flash of the raw unstyled content until the styles load and update the page to look as intended.

In the old days, the quick fixes for FOUC were to:

  • Move or even inline critical styles in the <head> to force the browser to load them before the content. These should be kept short as possible as they block the loading of the rest of the page.
  • Set certain styles such as user's preferred theme on the server-side (e.g., <html class="dark">) to "bake" it into the HTML instead of doing it on page load with a client-side script.

I recently ran into FOUC while working on my personal website. Here's what happened:

FOUC Example

User's POV:

But what's going on? Well, it's a specific form of FOUC that would be more appropriate to call "Flash of Default Theme." The app loads the "light" theme by default and then realizes the user actually wanted the dark theme, so it course corrects but by that point it's already too late.

Let's see exactly where it happens. This project uses Next.js with the App router and Tailwind for styles. I wanted to have the following functionality:

  • Have a light and dark theme.
  • On first visit, follow the user's operating system preference to select the initial theme.
  • Store the selected theme for subsequent visits.
  • Provide a "theme switcher" button that allows the user to override the theme.

The app/layout.tsx component is very standard:

app/layout.tsx
export default async function RootLayout({ children }: RootLayoutProps) {
  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

Following Tailwind's docs for Dark mode, the Layout.tsx component looks something like:

components/layout/layout.tsx
'use client';

export default function Layout({ children }: LayoutProps) {
  const [theme, setTheme] = useState<string | undefined>(undefined);

  // Detect and set the initial theme
  useEffect(() => { // 💥 The problem is here!
    // Previously selected theme stored in localStorage
    const storedTheme = localStorage.getItem('theme');
    // System preference
    const preferredTheme = window.matchMedia('(prefers-color-scheme: dark)')
      .matches
      ? 'dark'
      : 'light';
    // Resolve the theme by order of precedence
    const resolvedTheme = storedTheme || preferredTheme;

    setTheme(resolvedTheme);
    localStorage.setItem('theme', newTheme);
    document.documentElement.classList.remove(oldTheme);
  }, []);

  return (
    <div>
      <Header />
      {children}
      <Footer />
    </div>
  );
}

Nothing too crazy here, we just use a useEffect hook that runs when the component is mounted and we:

  1. check if the user already has a theme preference stored in localStorage from a previous visit.
  2. otherwise, check if the user has a preferred operating system theme via prefers-color-scheme.
  3. Set the theme we detected in three places:
    • In a theme state variable so we can pass it down to components (e.g., a theme switcher button).
    • In localStorage so that their preference is remembered on the next visit (step 1 above).
    • In the DOM itself via document.documentElement so we end up with <html class="dark"> and let Tailwind take it from there.

But... the useEffect itself is the problem. 😭 app/layout.tsx is a server component that renders static HTML, so the initial page render looks something like:

<html>
  <head>
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

Then, our Layout.tsx, a client component, mounts client-side and runs its useEffect, which results in

<html class="dark">
  <head>
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

This is what we wanted but it happened too late. We already rendered with the light theme so the user got 💣 flashbanged 💣. If you add some styles to smoothly transition between themes, the problem is exacerbated - your fancy transition betrays you and draws even more attention to the FOUC by making it last longer.

We can't move this logic to app/layout.tsx because it's a server component that doesn't have access to browser APIs. We also can't just make Layout.tsx a server component. It needs to be a client component because it uses browser APIs like document and localStorage. So what can we do?

The solution

To be clear, the issue I am describing is related to SSR/SSG apps and intricacies between server and client components. I've spent some time researching possible solutions and, as it turns out, there isn't a nice, simple, and "clean" solution in Next.js right now...

There's a long discussion about exactly this issue in the Next.js repo:

The solutions/workarounds proposed in the discussion fall into two categories:

  1. Solutions that involve storing and loading the theme with HTTP APIs such as cookies() in layout.tsx.
  2. Solutions that involve breaking out of React and injecting a <script> above the content.

In all cases, the solutions try to work around the server-client boundary by injecting the theme outside the React app.

In my case, solution #1 is immediately out the window because my app is statically exported to GitHub Pages and I can't use any HTTP APIs. 😅 So, we're left with solution #2. Let's see how we can make it work.

The basic idea of solution #1 is to create a script like this:

components/theme-provider/script.js
function script() {
  try {
    const storedTheme = localStorage.getItem('theme');
    const preferredTheme = window.matchMedia('(prefers-color-scheme: dark)')
      .matches
      ? 'dark'
      : 'light';
    const resolvedTheme = storedTheme || preferredTheme;
    document.documentElement.classList.add(resolvedTheme);
  } catch (_) {}
}
Warning

This script will NOT be transpiled so it should not use any newer ECMAScript features to support as many browsers as possible. It will also be injected above the app content, so it should be kept as short as possible.

Then, we want to create a React Context to allow our React components to read and set the theme.

components/theme-provider/theme-provider.tsx
export interface ThemeContextProps {
  /** the current theme */
  theme?: string;
  /** update the current theme */
  setTheme: React.Dispatch<React.SetStateAction<string>>;
}

const ThemeContext = createContext<ThemeContextProps>(defaultThemeContextProps);

We can create a useTheme hook to make consuming the context friendlier:

export function useTheme() {
  return useContext(ThemeContext);
}

This will let our components read and set the theme like so:

const { theme, setTheme } = useTheme();
Note

theme and setTheme can only be used client-side and will result in a hydration error (more on that later) if an attempt is made to render based on these values on the server.

To use these in our components, we'll need to make sure we're rendering on the client, i.e.,

components/my-component/my-component.tsx
'use client';

export default function MyComponent() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  // sets mounted to true when rendered on the client
  useEffect(() => {
    setMounted(true);
  }, [])

  if (!mounted) return null;

  return (/* ... */);
}

Finally, to tie it all together, we create a custom ThemeProvider that wraps the ThemeContext.Provider and injects our script above the content.

components/theme-provider/theme-provider.tsx
/**
 * Provides ThemeContext to children
 */
export function ThemeProvider({ children }: ThemeProviderProps) {
  const [theme, setTheme] = useState<string | undefined>(() => {
    if (typeof window === 'undefined') return undefined;

    // Previously selected theme stored in localStorage
    try {
      const storedTheme = localStorage.getItem('theme');
      // System preference
      const preferredTheme = window.matchMedia('(prefers-color-scheme: dark)')
        .matches
        ? 'dark'
        : 'light';
      // Resolve the theme by order of precedence
      return storedTheme || preferredTheme;
    } catch (_) {}
    return undefined;
  });

  // Applies a new theme to DOM and localStorage
  function applyTheme(theme: string) {
    localStorage.setItem('theme', theme);
    document.documentElement.classList.remove('dark', 'light');
    document.documentElement.classList.add(theme);
  }

  const handleMatchMediaChange = useCallback(
    (event: MediaQueryListEvent) => {
      setTheme(event.matches ? 'dark' : 'light');
    },
    [setTheme],
  );

  // Change theme based on user preference
  useEffect(() => {
    const matchMedia = window.matchMedia('(prefers-color-scheme: dark)');
    matchMedia.addEventListener('change', handleMatchMediaChange);

    return () =>
      matchMedia.removeEventListener('change', handleMatchMediaChange);
  }, [handleMatchMediaChange]);

  // Apply the theme whenever the themeState changes
  useEffect(() => {
    if (theme) {
      applyTheme(theme);
    }
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <script
        suppressHydrationWarning
        dangerouslySetInnerHTML={{ __html: `(${script.toString()})()` }}
      />

      {children}
    </ThemeContext.Provider>
  );
}

There's a lot going on here, so let's break down a few important bits:

  • const [themeState, setThemeState] = useState<string | undefined>(() => {
      /* ... */
    });
    

    Same as before, this is our theme state variable that our components can use to read and set the state. We call it themeState because we wrap setThemeState with our own setTheme that has a couple of side effects and is what we pass into our ThemeContext. This time, we pass it an initializer function to set the initial theme from localStorage or prefers-color-scheme.

  • The applyTheme function actually updates the theme in both localStorage and the DOM (document.documentElement).

  • The setTheme callback is our wrapper for setThemeState that also calls applyTheme as a side-effect.

  • // Change theme based on user preference
    useEffect(() => { /* ... */ }
    

    This useEffect listens for changes to prefers-color-scheme (i.e., if the user changes their operating system or browser preference) and calls setTheme.

  • // Apply the theme whenever the themeState changes
    useEffect(() => { /* ... */ }
    

    This useEffect calls applyTheme to actually apply the theme (in localStorage and the DOM) whenever themeState changes.

  • <script dangerouslySetInnerHTML={{ __html: `(${script.toString()})()` }} />
    

    This is the secret sauce that makes it all work. This serializes our script from before as an IIFE (an anonymous function that is executed immediately) and places it in a <script> element just above our app content which makes it run first.

Then, to actually use it, we update our app/layout.tsx like so:

app/layout.tsx
export default async function RootLayout({ children }: RootLayoutProps) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

But wait... there's another issue...

Hydration Error

A hydration error. This tells us that the React tree rendered on the server for app/layout.tsx is different from what was rendered in the browser on the first render. Next.js catches and warns us about this because hydration mismatches can reduce performance.

No easy fix here, because that's exactly what our script is doing out of necessity. We just need to add suppressHydrationWarning to app/layout.tsx which suppresses this warning on just the <html> element, so we'll still be notified of other hydration warnings in our app itself.

app/layout.tsx
export default async function RootLayout({ children }: RootLayoutProps) {
  return (
    <html suppressHydrationWarning>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Et voilà, it works!

No FOUC example

Thoughts

The solution above satisfies my use case but is definitely hacky in some places, notably:

  • <script
      suppressHydrationWarning
      dangerouslySetInnerHTML={{ __html: `(${script.toString()})()` }}
    />
    

    Just the name dangerouslySetInnerHTML indicates that this is not something you should typically do, and (${script.toString()})() is just nasty.

  • suppressHydrationWarning again indicates that we are doing something unusual.

However, every other solution/workaround I found has these two elements. In fact, there's a great NPM package next-themes that solves this same issue and in just ~500 lines of code. It has a lot more bells and whistles than my basic solution above, including:

  • Support for an arbitrary number of themes
  • Sync the theme across browser tabs
  • Force specific pages to a specific theme
  • Indication of system or user-selected theme

But a look through its code exposes that it too uses both the (${script.toString()})() IIFE and suppressHydrationWarning.

If I were to do it again, I would definitely just use next-themes because of all its features, but I have a much better understanding of how and why it works like this after going through this exercise myself.

Ultimately, I think this is a DX deficiency in Next.js. It would be really great if we could just do something like this instead. Maybe someday.

app/layout.tsx
export default async function RootLayout({ children }: RootLayoutProps) {
  return (
    <html>
      <head>
        <script>
          document.documentElement.classList.add(localStorage.getItem('theme'));
        </script>
      </head>
      <body>{children}</body>
    </html>
  );
}

TL;DR

  • If you use Next.js, just use next-themes.
  • If you don't use Next.js, just use document.documentElement.classList.add("dark") in a <script> in <head>. 😉

Addendum 1

After I got the ThemeProvider working, I ran into a really confusing bug. It was working on some pages but not others, and only when deployed on GitHub Pages. It worked just fine when I ran the production build locally, and I spent hours troubleshooting it.

Well, as it turns out, it was because of a different hydration error. It was because I was using date-fns to format dates for blog posts. By default, it formats with the machine's/browser's local timezone, so it was working fine on my machine. But when I deployed to GitHub Pages, the static export was generated with the GitHub Actions runner's timezone, so when I opened the page, the server-side (i.e., statically generated) output one date, and then the hydrated client-side output another one and bam... ✨ hydration error ✨.

All I needed was to use @date-fns/utc to keep the timezones consistent, but man, that was confusing.