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.
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.
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.
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.
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.
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.
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.