Skip to content
Chapter 18Lesson 6

Theme switching without FOUC

Wire up next-themes for a React light and dark mode toggle that switches instantly, with no flash of the wrong theme on load.

Last lesson, “Dark mode tokens,” you styled a component for both themes, assuming something had already put .dark on <html>. This lesson builds that something. Picture a user on a dark-mode machine opening your app: for a fraction of a second they see a white flash, then the page snaps to dark. By the end you’ll have a theme toggle that switches instantly, and know why every piece of the setup has to be there.

That flash has a name: FOUC .

The flash is a timing problem. Trace the naive version you’d write with only what you know so far: read the saved theme in a component, add .dark to <html> when it’s dark. On load:

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

  2. The browser paints that HTML. This first paint, the user’s first pixels, happens before any 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, steps 2 to 4, is the flash.

The correction always runs after the first paint. Reading the theme inside React, whether in an effect or a provider that flips the class on mount, is structurally too late: the wrong pixels are already on screen. You can’t fix a paint that already happened, only set the class before it.

That dictates the fix. The only code that runs before the body paints is a synchronous <script> in the <head>: the browser runs a blocking inline <head> script while parsing, before painting the <body>. A tiny script there that reads localStorage and the OS preference and writes .dark onto <html> makes the very first paint correct, so no wrong frame exists.

The diagram traces both timelines: the top lane is the naive one above, the bottom the fixed one. The flash lives between “HTML arrives” and “first paint” — exactly the gap the inline <head> script fills.

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 lanes 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 carries 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, so the browser paints the default. First paint is LIGHT — 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: the first paint is already DARK, on the same marker where the naive lane painted light. No wrong frame ever existed, so nothing flashes.

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 corrects the class and repaints; the fixed lane’s class is already right, so nothing moves.

The rest of the lesson leans on one word from that timeline. Hydration happens after the first paint, which is precisely why a React-based theme fix arrives too late. That same timeline explains suppressHydrationWarning, which you met on <html> last chapter; soon you’ll derive it yourself.

You could hand-write that inline script, but it’s fiddly. The standard solution on a Next.js stack is next-themes . It does four things:

  • injects that synchronous <head> script, so the class is set before the first paint;
  • persists the 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 components.

You configure it once. Two moving parts: <ThemeProvider> wraps your app, holds the configuration, and injects the script; useTheme() is the hook your toggle calls. Install it:

Terminal window
npm i next-themes

<ThemeProvider> uses React context and effects, which makes it a Client Component. But the root layout app/layout.tsx owns the <html> and <body> tags and, as the previous chapter showed, must stay a Server Component. Adding 'use client' to host the provider there would turn the entire app into a client subtree.

That is what the <Providers> pattern from the previous chapter solves: a thin Client Component at app/_components/providers.tsx that carries 'use client' and wraps the app, rendered as a child by the server layout. It held a bare <ThemeProvider> before; now you configure it. The boundary stops at <Providers>, leaving <html> and <body> on the server.

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 previous chapter’s provider wrapper, now configured. Every app-wide provider gathers here: later chapters add the data-fetching client and the i18n provider beside <ThemeProvider>.

Each prop earns its place.

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

attribute="class" tells next-themes to express the theme as class="dark" on <html> — the exact hook 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 to attribute="data-theme", in the last section.)

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

defaultTheme="system" decides what a brand-new visitor gets before picking a theme: the OS preference, not a hardcoded light. A first-timer on a dark OS lands in dark.

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

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

<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 on toggle, and the whole UI does an ugly fade.

1 / 1

The attribute that isn’t on the provider, suppressHydrationWarning on <html>, 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 new problem, and this prop answers it.

The inline script mutates <html>’s class before React hydrates. So when React compares the live DOM against the server’s HTML, it finds 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 the fix working as designed: the class genuinely is different, because you changed it before hydration to win the race against first paint. suppressHydrationWarning tells React, “I changed this element’s attributes on purpose; don’t warn” — acknowledging a deliberate change, not hiding a problem.

Three things to hold onto, each easy to get backwards:

  • It goes on <html> specifically, the element the script mutates. The class lands there, so the acknowledgement does too.
  • It is shallow. It silences mismatch warnings for that one element’s own attributes only, not for any descendant, so it can’t mask a real mismatch deeper in your tree.
  • It is not a general “make hydration errors go away” switch. Putting it elsewhere to quiet a real mismatch just hides a bug. It belongs here only, because the mutation is intentional and external to 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 runs, 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 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.

The mismatch problem returns in the button the user clicks. A toggle wants to show the icon for the current theme, a sun in light mode and a moon in dark. But the server doesn’t know the theme, so rendering that icon there makes React hydrate and find a different icon than sent.

Two approaches avoid this. Lead with the one that needs no React state.

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>
);
};

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

For a simple light/dark switch, default to the CSS-only swap: it sidesteps the mismatch by construction, no hooks, no placeholder. Reach for the mount-gate only when the content, not just the styling, branches on the theme.

Both toggles read resolvedTheme, not theme, to decide the next value — the one useTheme() subtlety worth a look.

useTheme() returns { theme, setTheme, resolvedTheme, systemTheme, themes }. Two values look interchangeable but aren’t, and mixing them up is the classic next-themes bug. The distinction is setting versus result:

  • theme is the setting the user picked, and can be the literal string "system".
  • resolvedTheme is the concrete theme in effect, always "light" or "dark", after system resolves against the OS preference.

When the user chose “system” and their OS is dark:

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

So a toggle that computes the next theme from theme breaks the moment theme is "system": there’s no sensible opposite to flip to. Read resolvedTheme whenever you need what’s actually on screen, which is why both toggles above flip on resolvedTheme === 'dark'. A light/dark toggle won’t need the systemTheme and themes the hook also returns.

Now build the swap yourself. The exercise 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 teaches this standalone preview what dark: means; in a real app, last lesson’s @custom-variant line does that while next-themes toggles the class.

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 confirm the switch works and double as triage: when a teammate says “dark mode is broken,” they tell you which half failed.

  1. Open DevTools, inspect <html>, and toggle the theme. The class attribute should flip between absent and "dark" on every click.

    • No class ever appears. 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 is fine; the problem is on last lesson’s Tailwind side — the @custom-variant dark line or the token overrides in globals.css.
  2. Hard-reload with dark active and watch the first frame. No flash means the inline script set the class before paint. A flash means the script isn’t running: the provider is missing or misplaced, or a competing effect-based setter is fighting it. Let next-themes own the class.

These events fire in a fixed order. Drag them into place.

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

Some apps ship more than two themes: a marketing site with brand variants, or a high-contrast mode. The same machinery scales — switch <ThemeProvider> to attribute="data-theme", pass themes={['light', 'dark', 'blue', 'high-contrast']}, and add a [data-theme="blue"] { … } token block in globals.css for each. It’s last lesson’s token model, keyed off an attribute instead of .dark. Rare in SaaS dashboards, common on marketing sites; you won’t build it here.