Skip to content
Chapter 18Lesson 6

Theme switching without FOUC

Use the next-themes library to build a React light and dark mode toggle that switches instantly without the flash of the wrong theme on page load.

Last lesson, “Dark mode via semantic tokens,” you styled a component to render correctly in both themes, assuming something had already put .dark on <html>. This lesson builds that something, and it’s trickier than it sounds because of one specific failure. Picture a user who keeps their machine in dark mode opening your app. For a fraction of a second they see a white flash, then the page snaps to dark. That flash is the bug this lesson exists to fix. By the end you’ll have a theme toggle that switches instantly with no flash, and you’ll be able to explain why every piece of the setup has to be there, instead of trusting that a guide told you to add it.

That flash has a name: it’s FOUC , a flash of the wrong-themed content before the right theme takes hold. Everything below this point is about preventing it.

Before reaching for a tool, let’s see exactly when the flash happens, because the timing is the whole problem. Picture the naive version you’d write with only what you know so far, and watch where it breaks.

You’d put the toggle in a component, have it read the saved theme, and add .dark to <html> when the saved theme is dark. That seems reasonable. Now trace what actually happens when the page loads:

  1. The server renders the page. But the server doesn’t know the user’s theme: the preference lives in the browser (in localStorage) and in the OS setting, and the server can read neither. So it emits HTML with the default theme and no .dark class.

  2. The browser receives that HTML and paints it. This is the first paint, the user’s first pixels, and it happens before any of your React code runs. So the screen shows the default light theme.

  3. React loads and hydrates. Now your code runs: it reads the saved theme, sees "dark", and adds .dark to <html>.

  4. The page repaints in dark. The user saw light, then dark. That gap between step 2 and step 4 is the flash.

The root cause points straight at the fix: the correction runs after the first paint. Any approach that reads the theme inside React, whether an effect or a provider that flips the class on mount, is structurally too late. By the time React is running, the wrong pixels are already on the user’s screen. You can’t fix a paint that already happened; you can only set the class before it.

So the fix has a precise shape, and we can describe it before naming any package: the class has to be written by code that runs before the body paints. The code that runs that early is a plain synchronous <script> in the <head>. The browser executes a blocking <head> script inline while it’s still parsing, before it paints the <body>. Slot a tiny script there that reads localStorage and the OS preference and writes .dark onto <html>, and the very first paint is already correct. There’s no flash, because there was never a wrong frame to flash.

The diagram below makes that concrete. Scrub through both lanes: the top lane is the naive timeline you just traced, the bottom is the fixed one. The flash lives between “HTML arrives” and “first paint,” and the inline <head> script slots into exactly that gap to close it.

Step 1 of 6. Naive timeline now at: Server renders. Fixed timeline now at: Server renders. The first paint marker falls between “HTML arrives” and the first paint in both lanes.

Naive — theme set in React ends in a flash
Server renders theme unknown
HTML arrives no .dark class
First paint: LIGHT
React hydrates
Effect reads sees dark
Repaint: DARK
Fixed — inline <head> script no flash
Server renders theme unknown
HTML arrives + inline <head> script
First paint: DARK
React hydrates already correct
Script runs sets .dark on <html>

The server renders both ways the same: it can’t read localStorage or the OS setting, so no theme class goes out.

Step 2 of 6. Naive timeline now at: HTML arrives. Fixed timeline now at: HTML arrives with inline head script. The first paint marker falls between “HTML arrives” and the first paint in both lanes.

Naive — theme set in React ends in a flash
Server renders theme unknown
HTML arrives no .dark class
First paint: LIGHT
React hydrates
Effect reads sees dark
Repaint: DARK
Fixed — inline <head> script no flash
Server renders theme unknown
HTML arrives + inline <head> script
First paint: DARK
React hydrates already correct
Script runs sets .dark on <html>

The naive HTML arrives bare. The fixed HTML arrives carrying one extra thing: an inline <head> script, loaded but not yet run.

Step 3 of 6. Naive timeline now at: First paint: light — the flash. Fixed timeline now at: HTML arrives with inline head script. The first paint marker falls between “HTML arrives” and the first paint in both lanes.

Naive — theme set in React ends in a flash
Server renders theme unknown
HTML arrives no .dark class
First paint: LIGHT
React hydrates
Effect reads sees dark
Repaint: DARK
Fixed — inline <head> script no flash
Server renders theme unknown
HTML arrives + inline <head> script
First paint: DARK
React hydrates already correct
Script runs sets .dark on <html>

Naive lane: nothing has set the theme yet, so the browser paints the default. First paint is LIGHT, and this is the flash.

Step 4 of 6. Naive timeline now at: First paint: light — the flash. Fixed timeline now at: Script runs, sets .dark. The first paint marker falls between “HTML arrives” and the first paint in both lanes.

Naive — theme set in React ends in a flash
Server renders theme unknown
HTML arrives no .dark class
First paint: LIGHT
React hydrates
Effect reads sees dark
Repaint: DARK
Fixed — inline <head> script no flash
Server renders theme unknown
HTML arrives + inline <head> script
First paint: DARK
React hydrates already correct
Script runs sets .dark on <html>

Fixed lane: the inline script runs before the body paints and writes .dark onto <html>, sliding into the gap right before the marker.

Step 5 of 6. Naive timeline now at: First paint: light — the flash. Fixed timeline now at: First paint: dark — no flash. The first paint marker falls between “HTML arrives” and the first paint in both lanes.

Naive — theme set in React ends in a flash
Server renders theme unknown
HTML arrives no .dark class
First paint: LIGHT
React hydrates
Effect reads sees dark
Repaint: DARK
Fixed — inline <head> script no flash
Server renders theme unknown
HTML arrives + inline <head> script
First paint: DARK
React hydrates already correct
Script runs sets .dark on <html>

Fixed lane: now the first paint is already DARK, on the same marker where the naive lane painted light. There was never a wrong frame, so there’s no flash.

Step 6 of 6. Naive timeline now at: React hydrates, the effect reads localStorage, then the page repaints dark. Fixed timeline now at: React hydrates, already correct. The first paint marker falls between “HTML arrives” and the first paint in both lanes.

Naive — theme set in React ends in a flash
Server renders theme unknown
HTML arrives no .dark class
First paint: LIGHT
React hydrates
Effect reads sees dark
Repaint: DARK
Fixed — inline <head> script no flash
Server renders theme unknown
HTML arrives + inline <head> script
First paint: DARK
React hydrates already correct
Script runs sets .dark on <html>

React hydrates in both lanes. The naive lane must correct the class and trigger a repaint; the fixed lane’s class is already right, so nothing moves.

This is also where a detail from earlier finally pays off. Back in “The root layout” in the previous chapter, you met suppressHydrationWarning as the targeted fix for one element whose mismatch is expected and harmless, and you saw it sit on <html> in exactly this kind of setup. You now have the timeline that explains why it’s there, and in a moment you’ll derive that prop yourself instead of taking it on faith.

One word in that timeline deserves a quick refresher, since the rest of the lesson leans on it. Hydration is React’s second pass: in the browser, it walks the same component tree the server already rendered, adopts the existing HTML, and wires up event handlers. You met it in the previous chapter. The one thing that matters here is that hydration happens after the first paint, which is precisely why a React-based theme fix is too late.

You could hand-write that inline script, but you shouldn’t. It’s fiddly to get right (reading storage, falling back to the OS preference, avoiding its own flash), and it’s already a solved problem. The standard solution on a Next.js stack is a tiny package called next-themes . It’s about 3KB, and its entire job is the thing the last section described. It does four things:

  • injects that synchronous <head> script for you, so the class is set before the first paint;
  • persists the user’s choice to localStorage, so it sticks across visits;
  • listens to the OS prefers-color-scheme setting, so "system" mode follows the OS live; and
  • exposes a React hook, useTheme(), to read and change the theme from your components.

You install it and configure it once, and you never write the inline script yourself. That’s the part you’re paying 3KB to skip. There are only two moving parts to learn, and the rest of the lesson wires them together:

  • <ThemeProvider> wraps your app, holds the configuration, and injects the script.
  • useTheme() is the hook your toggle button calls to read and set the theme.

Install it:

Terminal window
npm i next-themes

There’s a placement constraint here that you already have the tools to reason about, so let’s reason about it rather than copy a recipe. <ThemeProvider> uses React context and effects, which makes it a Client Component. But app/layout.tsx is the root layout: it owns the <html> and <body> tags, and as you saw in the previous chapter, it must stay a Server Component. Dropping 'use client' at the top of the root layout to host the provider would turn your entire app into a client subtree, which is exactly what you don’t want.

This is the <Providers> pattern the previous chapter set up: a thin Client Component at app/_components/providers.tsx that carries the 'use client' directive and wraps the app, and the server layout renders it as a child. Back then the wrapper was essentially empty, holding a bare <ThemeProvider>; now you fill in its configuration. The 'use client' boundary stops at <Providers>, so <html> and <body> stay on the server. Here are both files.

app/_components/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
);
}

The single client-side provider wrapper from the previous chapter, now configured. Back then <ThemeProvider> had no props. This is also the one spot where every app-wide provider gathers: later chapters add the data-fetching client and the i18n provider right here, beside <ThemeProvider>.

Each of the four props on <ThemeProvider> earns its place. Here’s what each one does.

<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>

attribute="class" tells next-themes to express the theme as class="dark" on <html>. That’s the exact hook the last lesson’s @custom-variant dark (&:is(.dark *)) reads, so this prop and that variant are two ends of the same wire. (For a multi-theme app you’d switch this to attribute="data-theme", which the last section covers.)

<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>

defaultTheme="system" decides what a brand-new visitor gets before they’ve ever picked a theme: the OS preference, not a hardcoded light. A first-time visitor on a dark OS lands in dark.

<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>

enableSystem makes "system" a real, selectable theme value and activates the OS listener. It’s what makes defaultTheme="system" valid, and what keeps the page tracking the OS when the user never overrides it.

<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>

disableTransitionOnChange briefly switches off CSS transitions during the swap. Without it, any transition-colors utilities on the page animate every color at once when you toggle, and the whole UI does an ugly half-second fade. It defaults to off, and turning it on is the better choice.

1 / 1

The one attribute that isn’t on the provider, suppressHydrationWarning sitting on <html> in the layout, is the one that confuses people, so it gets its own section.

<html lang="en" suppressHydrationWarning>

Why suppressHydrationWarning belongs on <html>, and only there

Section titled “Why suppressHydrationWarning belongs on <html>, and only there”

The fix from the first section creates a brand-new problem, and this prop is the answer to it. The chain runs as follows.

The inline script mutates <html>’s class before React hydrates, which is the entire point of it. So when React does hydrate and compares the DOM it finds against the HTML the server sent, it sees a discrepancy: the server sent <html> with no class, but the live DOM has <html class="dark">. React calls that a hydration mismatch and logs a warning.

That mismatch is not a bug; it’s the fix working as designed. The class genuinely is different, because you deliberately changed it before hydration to win the race against first paint. suppressHydrationWarning is how you tell React, “I know this one element’s attributes were changed on purpose before hydration; don’t warn about it.” You’re not hiding a problem, you’re acknowledging a change you made deliberately.

Three precise things to hold onto, because each one is easy to get backwards:

  • It goes on <html> specifically, because <html> is the element the script mutates. The class lands there, so the acknowledgement goes there.
  • It is shallow. It silences mismatch warnings for that one element’s own attributes only, not for any of its descendants. That means it can’t accidentally mask a real mismatch deeper in your tree, which is exactly why it’s safe here and why you don’t scatter it around.
  • It is not a general “make hydration errors go away” switch. Putting it on some other element to quiet a real mismatch just hides a real bug. It belongs here, and only here, because the mutation is intentional and comes from outside React.

Check your model of it:

Putting suppressHydrationWarning on <html> is the right call here. Which statements explain why it’s correct in this specific spot? Select all that apply.

The pre-paint script rewrites <html>’s class before React gets to run, so React is bound to find a class that was never in the server’s HTML — the difference is on purpose, not a defect.
It only quiets warnings about <html>’s own attributes, so a real mismatch further down the tree would still surface.
It makes React skip hydrating <html> altogether, so its class can never end up in conflict.
Anything styled with a theme token like bg-background or text-foreground needs it too, or those utilities won’t line up between server and client.

Now for the part the user actually clicks. The same mismatch problem comes back one level down. A toggle naturally wants to show the icon for the current theme: a sun in light mode, a moon in dark. But the server doesn’t know the theme (the same root cause as the flash), so if the button renders the current-theme icon during the server render, React hydrates and finds a different icon than the server sent. That’s a mismatch on the button.

There are two clean ways out. We’ll lead with the one that needs no React state at all, since it’s the right choice for a plain light/dark toggle.

app/_components/theme-toggle.tsx
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
export const ThemeToggle = () => {
const { resolvedTheme, setTheme } = useTheme();
return (
<button
type="button"
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<Sun className="inline dark:hidden" />
<Moon className="hidden dark:inline" />
</button>
);
};

This is the default for a plain light/dark toggle. Both icons always render, so the markup is byte-for-byte identical on server and client and there’s nothing to mismatch. Only CSS decides which one is visible, keyed off the .dark class the inline script already set before paint. useTheme() is used purely to write on click, through setTheme, which runs after hydration and is always safe.

So for a simple light/dark switch, the CSS-only swap is the default: it sidesteps the mismatch by construction, with no hooks and no placeholder. Reach for the mount-gate only when the control’s content, not just its styling, has to branch on the theme.

You may have noticed that both toggles read resolvedTheme, not theme, to decide the next value. That choice is the one useTheme() subtlety that trips most people up, so it’s worth a closer look.

useTheme() hands back an object: { theme, setTheme, resolvedTheme, systemTheme, themes }. Two of those values look interchangeable but aren’t, and mixing them up is the classic next-themes bug.

The distinction is the setting versus the result:

  • theme is the setting the user picked, and it can be the literal string "system".
  • resolvedTheme is the concrete theme in effect right now, always "light" or "dark", after system has been resolved against the actual OS preference.

When the user has chosen “system” and their OS is dark, here is what each one holds:

const { theme, resolvedTheme } = useTheme();
theme; // 'system' — the setting the user chose
resolvedTheme; // 'dark' — what 'system' actually resolves to right now

This makes the trap clear. A toggle that computes the next theme from theme breaks the moment theme is "system", because there’s no sensible “opposite of system” to flip to. Always read resolvedTheme when you need to know what’s actually on screen, which is why both toggles above flip on resolvedTheme === 'dark'. (The hook returns more than this, namely systemTheme and themes, but you won’t need them for a light/dark toggle.)

Now build the swap yourself. The exercise below renders your toggle on a light surface and again under a .dark ancestor, so you can watch the icon switch as the variant fires. The <style type="text/tailwindcss"> line at the top is just the harness teaching this standalone preview what dark: means. In a real app, the @custom-variant line from last lesson’s globals.css does that, and next-themes toggles the class for real.

The same toggle is rendered twice: on a light page (top) and under a .dark ancestor (bottom). Right now both glyphs show in both rows. Add visibility classes to the two spans so the sun (☀) shows only in the light row and the moon (☾) shows only in the dark row, matching the target. The glyphs are plain text so the exercise stays about the dark: variant, not about importing an icon set.

Target
Your output LIVE

Two checks turn this from “I think it works” into “I watched it work.” They also double as a triage routine: when a teammate says “dark mode is broken,” these two checks tell you which half is broken. It’s worth building the habit now.

  1. Open DevTools and inspect the <html> element, then toggle the theme. The class attribute should flip between absent and "dark" on every click. What each outcome tells you:

    • No class ever appears. The problem is on this side: the provider isn’t wrapping the tree, or attribute is misconfigured. Start at app/_components/providers.tsx.
    • The class flips, but the colors don’t change. The wiring here is fine; the problem is on the Tailwind side from last lesson, either the @custom-variant dark line or the token overrides in globals.css. A class that’s present but changes no colors points at last lesson; no class at all points at this one.
  2. Hard-reload the page with dark active, and watch the first frame. No flash means the inline script is doing its job and the class is set before paint. A flash means the script isn’t running: most likely the provider is missing or misplaced, or you added a competing effect-based theme setter that’s fighting it. The fix is to remove the stray setter and let next-themes own the class.

To lock in the timing model that all of this rests on, put the load sequence back in order. Drag these into the order they actually happen.

Order the events from a fresh page load with dark mode active, from server to first user interaction. Drag the items into the correct order, then press Check.

The server renders the page with the default theme — it can’t read the browser or the OS
The inline <head> script runs and sets .dark on <html>
The browser paints the first frame — already dark, so no flash
React hydrates the page, finding the class already in place
The user clicks the toggle, and setTheme flips the theme

One extension is worth naming so you recognize it when a project needs it. Some apps ship more than two themes: a marketing site with brand variants, or an app with a high-contrast mode. The model stretches cleanly to cover them. Switch <ThemeProvider> to attribute="data-theme", pass the list with themes={['light', 'dark', 'blue', 'high-contrast']}, and add a [data-theme="blue"] { … } token block in globals.css for each one. It’s the same token model as last lesson, just keyed off an attribute instead of the .dark class. This is rare in SaaS dashboards and common on marketing and agency sites. You won’t build it here; just know the same machinery scales to it.