Next.js Dark Mode Without the Flash (Tailwind v4)
Add dark mode to your Next.js App Router app without the white flash. Complete guide covering next-themes, Tailwind CSS v4 setup, and Cloudflare Pages.
On this page
Every dark mode implementation has the same enemy: the flash.
The page renders in light mode, then instantly switches to dark. It happens because JavaScript applies the CSS class after the HTML is already painted and by then it's too late.
This guide covers the complete flash-free setup for Next.js App Router: next-themes, Tailwind CSS v4, and Cloudflare Pages deployment. DevEncyclopedia itself runs this exact stack, so this is written from a live production implementation, not theory.
If you're just starting out with your Next.js setup, our guide on setting up environment variables in Next.js covers the project scaffolding side. This guide picks up at dark mode.
Why the Flash Happens
The flash is a timing problem. Browsers parse HTML first, then load CSS, then execute JavaScript. By the time your JS reads localStorage and adds the dark class to <html>, the browser has already painted the page in light mode.
The only fix is running code before that first paint. This means an inline blocking script injected directly into <head> something that executes synchronously before any rendering happens. That's exactly what next-themes ThemeProvider does under the hood.
The Render Timeline, Stage by Stage
Understanding the fix means understanding the order the browser does things in. A page load is a pipeline, and the theme class has to land at one very specific point in it.
The browser receives HTML bytes and starts building the DOM. It hits a <link rel="stylesheet"> in the head and blocks rendering until that CSS parses. It hits an inline <script> and executes it immediately, pausing the parser. Only once the head is fully processed does the first paint happen.
Your React bundle is not part of that sequence. Next.js loads it deferred, so it executes after the document is parsed and, critically, after the first paint. Any theme logic living inside a component runs too late by definition.
| Stage | What runs | Can it set the theme class? |
|---|---|---|
| 1. HTML parsing begins | The browser builds the DOM from streamed markup | Yes, if the class is already in the server HTML |
| 2. Inline head script | Executes synchronously, parser paused | Yes: this is the only reliable hook |
| 3. Stylesheet parsed | CSS rules matched against the current DOM | Too late to change the class, but the class is read here |
| 4. First paint | The user sees pixels for the first time | No |
| 5. Deferred bundle executes | Hydration, effects, context providers | No: the flash has already happened |
Why Only a Blocking Inline Script Works
Most people try three other approaches before landing on the inline script. It is worth knowing exactly why each one fails, because the failure modes look different but share a cause.
useEffect runs after paint by design. React commits the DOM, the browser paints, then effects flush. useLayoutEffect runs earlier in the commit, but still after hydration, which is still after the deferred bundle downloaded and executed.
An external script with async or defer has no guaranteed ordering relative to first paint, and it costs a network round trip you do not need. A CSS-only prefers-color-scheme media query does paint correctly with zero JavaScript, but it cannot honour a user override, so it rules out having a toggle at all.
That leaves one option: a small, synchronous, inline script in <head>. It makes no network request, it blocks the parser for well under a millisecond, and it runs before any pixels are committed.
<head>
<script>
(function () {
try {
var t = localStorage.getItem("theme");
if (t === "dark") document.documentElement.classList.add("dark");
} catch (e) {}
})();
</script>
</head>The try/catch is not decoration. Safari in Private Browsing and some enterprise browser policies throw on localStorage access instead of returning null. An uncaught throw here kills the script before the class is applied, and it takes any other head script in the same block down with it.
Keep the script tiny. Every byte of it is parser-blocking and sits on the critical rendering path of every single page view on your site.
Install next-themes
npm install next-themesnext-themes handles system preference detection, localStorage persistence, and a React context layer so you don't write any of that logic yourself. It also injects the anti-flash blocking script automatically which is the main reason to use it over a hand-rolled solution.
Wrap the App in ThemeProvider
- 1
Add ThemeProvider to layout.tsx
tsx — app/layout.tsximport { ThemeProvider } from 'next-themes' export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <body> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} </ThemeProvider> </body> </html> ) }suppressHydrationWarningis required on the<html>element not<body>. Thenext-themesblocking script sets theclassattribute on<html>before React hydrates, which would normally trigger a mismatch warning. This prop silences that specific warning without suppressing others elsewhere in the tree.disableTransitionOnChangeprevents CSS transitions from firing when the theme switches. Without it, color properties animate from light values to dark values on every toggle. This causes a secondary, subtler flash due to your own transition styles. - 2
Understand the anti-flash script
This is the critical piece most guides skip. When
ThemeProviderrenders, it automatically injects a tiny inline blocking script into<head>. That script runs synchronously before the browser paints anything, readslocalStorage, and sets the correct class on<html>immediately.Because this happens before React hydrates, the class in the DOM doesn't match the server-rendered HTML. That's the mismatch
suppressHydrationWarningexists to handle. The script and the warning suppression are a matched pair: you need both. - 3
Verify the script is injected
Open DevTools and go to the Elements tab. Look at the
<html>element. Before the page finishes loading, it should already haveclass="dark"orclass="light"set. If the class appears there immediately, the inline script is working.If the class only appears after the page fully loads, the provider isn't injecting the script correctly. Double-check that
ThemeProvideris wrappingchildrendirectly insidelayout.tsx, and thatattribute="class"is set.
Tailwind CSS v4: What Changed from v3
Tailwind v4 changed how dark: utilities are configured. In v3, you set darkMode: 'class' in tailwind.config.js. In v4, configuration moved to CSS. This is the single most common reason dark mode utilities stop working after upgrading.
// v3 only: has no effect in Tailwind CSS v4
module.exports = {
darkMode: 'class',
}@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));This tells Tailwind that dark: utilities apply whenever an ancestor has the dark class which is exactly what next-themes adds to <html>. If dark mode utilities aren't working in v4, adding this line is the fix.
Class Strategy vs Media Strategy
Tailwind gives you two ways to define what dark: means, and the choice decides whether your users can override their operating system at all.
The media strategy is Tailwind's default: dark: compiles to a @media (prefers-color-scheme: dark) query. It needs zero JavaScript and can never flash, because the browser knows the OS preference before it paints anything. The catch is that there is no override. A visitor on a dark machine cannot choose your light theme.
The class strategy compiles dark: to a descendant selector rooted at .dark. That is what makes a toggle possible, and it is also what creates the flash problem in the first place. Nearly every site that ships a toggle is on this strategy.
| Media strategy | Class strategy | |
|---|---|---|
| Tailwind v4 setup | Default, no configuration needed | Requires an @custom-variant declaration |
| Compiles to | @media (prefers-color-scheme: dark) | A .dark selector on an ancestor element |
| JavaScript required | None at all | An inline bootstrap script in the head |
| User can override the OS | No | Yes, this is the whole point |
| Flash risk | None | Yes, unless the bootstrap script runs first |
| Best for | Docs and marketing pages with no toggle | Any product or blog that ships a theme switcher |
You can have both. Point @custom-variant dark at a rule with two arms, so visitors who have never touched your toggle still get their OS preference honoured with no JavaScript involved.
@import "tailwindcss";
/* Explicit class wins; otherwise fall back to the OS preference. */
@custom-variant dark {
&:where(.dark, .dark *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where(:not(.light, .light *)) {
@slot;
}
}
}The first arm fires whenever .dark sits on an ancestor, which is the toggle path. The second fires when the OS prefers dark and nothing has explicitly opted into light, which covers first-time visitors before a single line of your JavaScript has run.
For the second arm to behave, your bootstrap script has to write light onto <html> when the stored preference is light, not just leave the attribute empty. Otherwise a user who chose light on a dark-mode machine gets dark styles until they toggle again.
:where() is doing real work here: it contributes zero specificity, so your dark: utilities stay at exactly the same weight as their light counterparts and override order stays predictable. If that selector is unfamiliar, our guide on modern CSS selector patterns covers the same family of zero-specificity selectors in more depth.
Adding a Theme Toggle
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
)
}The mounted check is essential. During server rendering and initial hydration, useTheme() returns undefined for theme because the actual value isn't known until the client reads localStorage. Rendering the button before mounting would cause a hydration mismatch and flash the wrong icon.
Returning null until mounted prevents both the mismatch and the icon flicker. The button simply doesn't render until the client has confirmed the real theme.
Three-State Theming: Light, Dark, and System
Most production toggles have three states, not two: light, dark, and system. System means follow the operating system and keep following it, so the page changes when the user's laptop flips into night mode at sunset.
The distinction matters for storage. Two states means you persist light or dark. Three states means you also persist system, and you resolve it to a concrete theme every time you read it.
export type ThemePreference = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "theme";
export function readPreference(): ThemePreference {
try {
const v = localStorage.getItem(STORAGE_KEY);
return v === "light" || v === "dark" ? v : "system";
} catch {
return "system";
}
}
export function systemTheme(): ResolvedTheme {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
export function resolve(pref: ThemePreference): ResolvedTheme {
return pref === "system" ? systemTheme() : pref;
}Note that a missing key resolves to system, not light. Treating absence as light is the single most common bug in three-state implementations, because it silently ignores the OS preference for every first-time visitor, which is most of your search traffic.
The system state also needs a live subscription. prefers-color-scheme is a media query, and media queries emit change events. If you read it once on mount and never listen again, a user whose OS switches at dusk stays on the stale theme until they reload the page.
"use client";
import { useEffect, useState } from "react";
import { resolve, type ThemePreference, type ResolvedTheme } from "@/lib/theme";
export function useResolvedTheme(preference: ThemePreference): ResolvedTheme {
const [resolved, setResolved] = useState<ResolvedTheme>("light");
useEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const next = resolve(preference);
setResolved(next);
document.documentElement.classList.toggle("dark", next === "dark");
};
apply();
// Only the "system" preference should keep tracking the OS.
if (preference !== "system") return;
mql.addEventListener("change", apply);
return () => mql.removeEventListener("change", apply);
}, [preference]);
return resolved;
}addEventListener on a MediaQueryList is supported in every browser shipped since 2020. The older addListener method still functions but is deprecated, and the TypeScript DOM types flag it as such.
The cleanup function matters more here than in a typical effect. Without it, every mount stacks another listener, and after a handful of client-side navigations you are running the same theme calculation a dozen times for one OS change.
localStorage vs Cookies for Persistence
Where you persist the preference decides what the server is allowed to know. This is the fork in the road for anyone who wants server-rendered, theme-aware markup.
localStorage is client-only storage. It is never attached to a request, so a Server Component, a route handler, and the build-time HTML generator all have exactly zero visibility into it. That is precisely why the bootstrap script exists: it is the only thing that can bridge storage to the DOM before paint.
A cookie, by contrast, travels with every request. If you persist the theme in a cookie, the server can read it while rendering and emit <html class="dark"> in the HTML itself, which removes the need for a bootstrap script entirely.
| Concern | localStorage | Cookie |
|---|---|---|
| Readable on the server | No | Yes, via cookies() in the App Router |
| Sent on every request | No | Yes, adds bytes to each request |
| Works with force-static pages | Yes | No: reading cookies() opts the route into dynamic rendering |
| Works with a fully static export | Yes | No server exists to read it |
| Needs a bootstrap script | Yes | No |
| Behaviour in private browsing | Access can throw, so wrap it in try/catch | Works for the session, cleared on exit |
import { cookies } from "next/headers";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const theme = (await cookies()).get("theme")?.value === "dark" ? "dark" : "";
return (
<html lang="en" className={theme}>
<body>{children}</body>
</html>
);
}This version has no hydration mismatch and no bootstrap script, because the class is already in the HTML the server sent. It also carries a real cost: calling cookies() marks the route as dynamic, so every page under this layout renders per request instead of being served from a static cache.
On a content site that is a bad trade. You give up build-time HTML, you give up cheap CDN caching, and on constrained runtimes you give up your CPU budget too. On an authenticated dashboard that already renders per request, the cookie approach is close to free.
There is a middle path: write the preference to both. Use localStorage plus the bootstrap script for the static pages, and mirror the value into a cookie so any genuinely dynamic route can read it without inventing a second source of truth.
function persist(pref: ThemePreference) {
try {
localStorage.setItem("theme", pref);
} catch {
// Storage blocked: the toggle still applies for this page view.
}
// Mirrored so dynamic routes can read the same value server-side.
document.cookie =
"theme=" + pref + "; path=/; max-age=31536000; SameSite=Lax";
}Reading Theme in Server Components
You mostly can't, and that's the right answer. Server Components run where there is no localStorage and no access to the user's OS theme preference unless it's been stored in a cookie. next-themes uses localStorage by default, so the theme isn't readable server-side without extra setup.
For most apps this is fine. The anti-flash inline script handles the initial client render correctly, and Tailwind's dark: utilities apply through CSS once the class is on <html>. If you genuinely need server-rendered theme-aware components, next-themes supports a cookie-based storageKey mode, but this adds complexity and doesn't work on edge runtimes.
Static Rendering and force-static
Static rendering makes the constraint concrete. When a page is prerendered at build time, one HTML file serves every visitor, and it was generated long before anyone's browser preference existed.
So the prerendered HTML has to commit to a single theme, and every hydrating render on the client has to claim the same one, or React finds a mismatch. This site prerenders in light mode and has its provider return light from the server snapshot for exactly that reason.
Getting this wrong produces a much worse version of the bug you set out to fix. If a theme-aware component reads the real theme during the hydrating render and disagrees with the served HTML, React can bail out and re-render the tree from scratch, which resets <html> and wipes the class your bootstrap script added. The flash comes back, later and more visibly. Our breakdown of how React Fiber schedules and commits work explains why that recovery path is so destructive.
Theming Images and Browser Chrome
Once the page itself stops flashing, two things still give the theme away: images that assume a white background, and the browser's own interface around your page.
Browser chrome is controlled by <meta name="theme-color">. On Android Chrome it tints the address bar, on iOS Safari it tints the status bar area, and on recent desktop Safari it colours the window frame. Ship two of them with media attributes and the browser picks the right one on its own.
import type { Viewport } from "next";
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0b0b0f" },
],
};In the App Router you declare these through the viewport export rather than writing tags by hand, and Next.js renders the matching meta tags into the head for you.
There is a subtlety worth knowing: theme-color keys off the OS preference, not your class. A visitor who overrides to light on a dark machine gets a dark address bar sitting above a light page. If that bothers you, ship a single tag with no media attribute and update it from your toggle handler.
// Keep the browser chrome in sync with an explicit user override.
function syncThemeColor(theme: "light" | "dark") {
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", theme === "dark" ? "#0b0b0f" : "#ffffff");
}Images are the other tell. Prefer solutions that need no JavaScript at all. An SVG that uses currentColor for its strokes and fills inherits whatever text colour the theme sets, which handles icons, arrows, and most line diagrams for free.
For raster screenshots you need two files. The <picture> element chooses between them with a media query, and it works before hydration because the browser resolves the source while parsing.
<picture>
<source srcset="/diagram-dark.png" media="(prefers-color-scheme: dark)" />
<img
src="/diagram-light.png"
alt="Next.js request lifecycle from HTML parse to hydration"
width="1200"
height="630"
/>
</picture>Same caveat as theme-color: <picture> reads the OS preference, not your class. If an image genuinely has to follow an explicit override, render both and hide one with a dark: utility. That costs a second download, so reserve it for images that carry real information rather than decoration.
Always keep width and height on both variants. Swapping images without intrinsic dimensions is a reliable way to trade a colour flash for a layout shift, which search engines measure and colour flashes they do not.
Avoiding Transition Flicker on Toggle
A global transition on colours feels pleasant on hover and terrible on a theme switch. If every element animates its background and text colour over 200ms, the toggle produces a slow, uneven wash where different parts of the page arrive at the new theme at slightly different moments.
The fix is to suppress transitions for the duration of the switch, then restore them. next-themes does this when you pass disableTransitionOnChange. Rolling it yourself takes about ten lines.
function setThemeWithoutTransition(next: "light" | "dark") {
const style = document.createElement("style");
style.appendChild(
document.createTextNode(
"*,*::before,*::after{transition:none !important}"
)
);
document.head.appendChild(style);
document.documentElement.classList.toggle("dark", next === "dark");
// Force a reflow so the new colours commit while transitions are still off.
void document.body.offsetHeight;
document.head.removeChild(style);
}Reading offsetHeight looks pointless and is the entire trick. It forces a synchronous style recalculation, so the browser commits the new colours while the override stylesheet is still in the document. Remove that stylesheet before the reflow and every transition fires as normal.
There is a cleaner option if you control your own CSS: never put transitions on color or background-color at the universal level. Scope them to interactive affordances instead, so a theme change repaints instantly while hover states still animate.
/* Avoid: every theme switch animates the entire page. */
* {
transition: background-color 200ms, color 200ms;
}
/* Prefer: only interactive elements animate. */
a,
button {
transition: color 150ms, opacity 150ms;
}Rolling Your Own Without next-themes
next-themes is small and handles every case above. If you already ship a provider tree, or you want zero dependencies on the critical path, the pattern is short enough to own outright. This site does exactly that, and it is worth walking through because the interesting parts are the constraints, not the code.
Two pieces do the work: a hand-written bootstrap script in the layout's <head>, and a client provider that publishes the current theme to components through context.
The provider reads the theme with useSyncExternalStore, which takes a subscribe function, a client snapshot, and a server snapshot. The server snapshot is the load-bearing one. It returns light unconditionally, matching the prerendered HTML, so the hydrating render can never disagree with what was served.
"use client";
import { useCallback, useEffect, useSyncExternalStore } from "react";
type Theme = "light" | "dark";
let listeners: Array<() => void> = [];
function subscribe(listener: () => void) {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}
function getSnapshot(): Theme {
try {
return localStorage.getItem("theme") === "dark" ? "dark" : "light";
} catch {
return "light";
}
}
// Pages are prerendered in light mode, so the hydrating render must agree.
function getServerSnapshot(): Theme {
return "light";
}
export function useThemeStore() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
// Keeps <html> authoritative if React re-applies the className it rendered.
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
const toggle = useCallback(() => {
const next: Theme = theme === "light" ? "dark" : "light";
try {
localStorage.setItem("theme", next);
} catch {
// Blocked storage: the toggle still applies for this page view.
}
document.documentElement.classList.toggle("dark", next === "dark");
for (const l of listeners) l();
}, [theme]);
return { theme, toggle };
}The useEffect that re-applies the class is not redundant with the bootstrap script. The root layout renders its own className on <html> for font variables and base utilities, and if React ever re-applies that attribute it drops the dark class the bootstrap script added. The effect makes the store the final authority once hydration is done.
The module-level listener array is what makes this a global store without a provider. Every component calling the hook re-renders when the toggle fires. Wrapping it in a context is still worth doing for ergonomics and typing, but the subscription itself does not need one.
The honest trade-off against the library: you now own three-state support, the prefers-color-scheme subscription, and transition suppression yourself. If you need all three, the dependency is the better deal. If you need a two-state toggle on a static site, this is roughly forty lines and no bundle cost.
Cloudflare Pages & Edge Deployment
If you're deploying to Cloudflare Pages, there's one thing to confirm: ThemeProvider and useTheme are client-side only. Cloudflare's Edge Runtime has no Node.js, no fs, and limited cookie APIs. The next-themes localStorage-based default is exactly what you want here nothing to configure differently.
What does matter: make sure you're not accidentally importing next-themes in a Server Component. Any component that calls useTheme() needs the 'use client' directive. The ThemeProvider in layout.tsx handles this internally so you don't add 'use client' to your layout file.
Also worth reading: our guide on enforcing code quality with Husky and lint-staged pairs well with any Next.js project setup, including Cloudflare-targeted ones.
How to Actually Verify There Is No Flash
A flash on a fast local dev server lasts a handful of milliseconds and your eye will simply miss it. Every method below is about slowing the load down until the failure becomes visible, or recording it so you can step through frames instead of trusting your reflexes.
- 1
Hard reload with the cache disabled
Open DevTools, tick
Disable cachein the Network tab, and reload with the panel open. A warm HTTP cache hides the flash because the document and the CSS arrive effectively instantly. Most reports of "it works on my machine" are really reports of a warm cache.Test in a fresh incognito window too. Your everyday profile already has a
themekey inlocalStorage, which means you have never actually tested the first-visit path that most of your visitors take. - 2
Throttle the network and the CPU
In the Network tab, set throttling to
Slow 4Gand reload. This stretches the gap between the document arriving and the deferred bundle executing from a few milliseconds to a few hundred. If your theme logic lives in a component rather than the head, that gap is the flash and it becomes impossible to miss.Add CPU throttling at 4x or 6x from the Performance panel for a closer match to a mid-range Android phone, which is what a large share of organic search traffic is actually holding.
- 3
Record a trace and read the filmstrip
The Performance panel captures a filmstrip of screenshots alongside the timeline. Record a reload, then step through the frames one at a time. If any frame shows light pixels before a dark frame, you have a flash, no matter how briefly it appeared in real time.
Find the first paint marker in the same trace. The theme class must already be on
<html>at that point. If the class is applied after it, no amount of CSS tuning will save you: the ordering is wrong. - 4
Watch the html element during load
Keep the Elements panel focused on
<html>while the page loads. It should already readclass="dark"before any content renders. If the class appears at the same moment the page becomes interactive, your logic is running in the deferred bundle and the inline script is either missing or throwing.If you suspect it is throwing, temporarily remove the
try/catchin a local build and reload. Any storage exception will then surface in the console instead of being swallowed. - 5
Test toggle, reload, and navigation separately
These are three different code paths and they fail independently. Toggling should be instant, with no colour wash. Reloading should keep the theme with no flash. A client-side navigation should never re-run the bootstrap script at all, so if the theme resets on navigation, something in your tree is re-rendering
<html>and dropping the class.One more case worth testing by hand: set the preference to system, then change your operating system between light and dark with the tab open. The page should follow within a frame or two. If it only updates after a reload, your
matchMediasubscription is missing or was cleaned up too eagerly.
Common Issues & Quick Fixes
- Flash still appears: confirm
suppressHydrationWarningis on the<html>element, not<body>. Also confirmThemeProviderwraps children inlayout.tsxdirectly. - Dark mode utilities not applying (Tailwind v4): add the
@custom-variant darkline toglobals.cssas shown above. - Toggle button flickers between states on load: add the
mountedcheck before rendering the button. - Theme resets on every page load: check
localStorageis accessible. Some browser privacy modes block it.next-themeshandles this gracefully but the theme won't persist. - Hydration warning still showing: confirm
suppressHydrationWarningis on the<html>element and thatThemeProviderusesattribute="class".
Frequently Asked Questions
What is the dark mode flash in Next.js?
The flash is a brief white-to-dark flicker that appears when the page first loads. The browser renders HTML in light mode by default, then JavaScript runs and adds the dark class to <html>. The fix is a blocking inline script injected by next-themes ThemeProvider that sets the class before the first paint.
Does next-themes work with the Next.js App Router?
Yes. Wrap your layout's children in <ThemeProvider> with attribute="class" and add suppressHydrationWarning to the <html> element. The anti-flash script is injected automatically. No additional configuration is needed for App Router vs Pages Router.
How do I set up Tailwind CSS v4 dark mode?
Add @custom-variant dark (&:where(.dark, .dark *)); to your globals.css after @import "tailwindcss". The old darkMode: 'class' config option from Tailwind v3 has no effect in v4. Once you add the variant, use dark: utility classes as normal.
Can I read the current theme in a Server Component?
Not with the default next-themes localStorage setup. The theme is only available on the client. Use useTheme() inside a Client Component. If you need server-side theme awareness, you'd need a cookie-based setup which adds complexity and doesn't work on Cloudflare's Edge Runtime.
Does next-themes work on Cloudflare Pages?
Yes. next-themes uses localStorage, not server-side cookies, so there's nothing special to configure for Cloudflare Pages or Cloudflare Workers. Just make sure any component using useTheme() has the 'use client' directive.
What does suppressHydrationWarning do?
It tells React to silently ignore hydration mismatches on that specific element. It's needed here because next-themes adds a class attribute to <html> via the inline blocking script before React hydrates. React would normally warn that the server-rendered HTML doesn't match the client HTML. suppressHydrationWarning suppresses only that warning on the element it's applied to, not globally across the tree.
It is deliberately shallow: it covers attributes and text on that one element, not its descendants. That is why it is safe to leave on <html> permanently. A real mismatch deeper in your tree will still be reported.
How do I let users follow the OS and still override it manually?
Store three values instead of two: light, dark, and system. Treat a missing key as system so first-time visitors get their OS preference. Resolve system to a concrete theme with matchMedia at read time, and subscribe to that media query's change event so the page keeps following the OS while the tab is open.
var pref = localStorage.getItem("theme") || "system";
var dark =
pref === "dark" ||
(pref === "system" &&
matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);That snippet is small enough to live inside the inline head script, which is exactly where it belongs. System resolution has to happen before first paint too, not just the explicit override.
Does the inline theme script hurt performance or Lighthouse scores?
No, as long as you keep it small. A typical bootstrap script is around 150 bytes, parses and executes in well under a millisecond, and makes no network request. It is parser-blocking by design, and that is the entire point: a non-blocking script cannot beat first paint.
It has no measurable effect on Largest Contentful Paint, and removing it makes Cumulative Layout Shift and perceived stability worse, because a theme repaint after paint is exactly the kind of visual instability those metrics were built to catch. Lighthouse's "avoid render-blocking resources" audit targets external stylesheets and scripts, not tiny inline ones.
If you have a Content Security Policy, the one real cost is that an inline script needs a nonce or a hash. Next.js can generate a nonce in middleware and pass it to the script tag, which keeps the policy strict without dropping the script.
How do I swap images between light and dark mode?
For icons and line art, use SVG with currentColor so the artwork inherits your text colour and needs no swapping at all. For screenshots, ship two files and let <picture> pick one with a prefers-color-scheme media query, which resolves during parsing and therefore cannot flash.
If the image must follow an explicit user override rather than the OS, render both and hide one with a dark: utility. Keep matching width and height on both so you do not trade a colour flash for a layout shift.
Dark mode without a flash comes down to one rule: the theme class has to be on <html> before the browser paints, and the only thing that runs that early is a synchronous inline script in the head.
Everything else follows from that rule. suppressHydrationWarning exists because the script writes to the DOM before React ever sees it. The mounted guard exists because the client knows the theme and the prerendered HTML does not. The transition suppression exists because your own CSS will otherwise animate the switch into a slow wash.
Pick your persistence layer deliberately. localStorage keeps every page static and requires the bootstrap script. A cookie removes the script and costs you static rendering. On a content site the first is almost always the right call.
Then verify it properly: throttle the network, disable the cache, open a fresh incognito window, and read the filmstrip frame by frame. A flash you cannot see on a fast desktop connection is still there on a mid-range phone.
If you are preparing for interviews, the hydration and rendering concepts behind this pattern come up constantly. Our Next.js interview questions guide covers the same ground from the other direction.
Related Articles
How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.
Husky + Prettier + lint-staged Setup for Next.js
Set up Husky v9, Prettier, and lint-staged in your Next.js project. Step-by-step guide covering pre-commit hooks with the correct 2026 config.
ARIA in React: Stop Using aria-label Wrong
Pages using ARIA average 41% more accessibility errors. Learn the correct ARIA patterns for React: icon buttons, modals, toasts, spinners, and tab panels.