Back to articles
Next.jsTailwind CSS

A Flicker-Free Dark Mode in Next.js App Router

How to build a theme toggle that respects system preference, persists the user's choice, and — crucially — never flashes the wrong theme on first paint.

Yash Thakur
4 min read
A Flicker-Free Dark Mode in Next.js App Router

The short answer: to kill the flash of incorrect theme in the Next.js App Router, run a tiny blocking inline script in <head> that reads localStorage (falling back to the prefers-color-scheme system preference) and sets the .dark class on <html> before the browser's first paint — so the correct theme is applied before any pixels render, rather than being swapped in after hydration.

Everyone can wire up a dark mode toggle. The part people get wrong is the flash of incorrect theme (FOIT) — that split second on load where a dark-mode user sees a blinding white page before JavaScript hydrates and corrects it. In the App Router, with server rendering, this is easy to get wrong and easy to fix once you understand why it happens.

Why the flash happens

Your server has no idea what theme the user prefers. It renders HTML with some default — usually light — and ships it. Only once the client JS runs can you read localStorage or the system preference and swap the class on <html>. Between first paint and that swap, the wrong theme is visible. The fix is to run a tiny, blocking script before the browser paints.

Set up Tailwind for class-based theming

Driving colors through CSS variables (rather than dark: on every element) means your whole palette flips by changing variables under :root and .dark. One source of truth, no dark:bg-x dark:text-y noise on every component.

Use next-themes and suppress hydration warnings

next-themes handles the blocking script, system-preference detection, and persistence for you. The two details that matter: wrap the app in ThemeProvider, and add suppressHydrationWarning to <html> — because the server renders without the class and the pre-paint script adds it, which React would otherwise flag as a mismatch.

The toggle itself — guard against hydration

A toggle button that reads theme will render differently on the server (where theme is undefined) than on the client. Render nothing theme-dependent until the component has mounted.

That placeholder <div> with the same dimensions as the button prevents a layout shift when the icon pops in after mount — a small thing that keeps your Cumulative Layout Shift score clean.

The result

System preference on first visit, a one-click override that persists, and no flash — because the correcting class is applied before the first paint, not after hydration. Three moving parts, no custom script to maintain, and a palette that lives entirely in CSS variables.

Written by Yash Thakur

Senior React Developer · 8+ years building for the web

More articles

Keep reading