Dev Encyclopedia
ArticlesTools

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Content

  • Articles
  • Tools
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

ยฉ 2026 Dev Encyclopedia

Privacy PolicyTermsDisclaimer
  1. Home
  2. /Blog
  3. /Next.js Dark Mode Without the Flash (Tailwind v4)
nextjs8 min read

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.

By Dev EncyclopediaPublished June 6, 2026
On this page

On this page

  • Why the Flash Happens
  • Install next-themes
  • Wrap the App in ThemeProvider
  • Tailwind CSS v4: What Changed from v3
  • Adding a Theme Toggle
  • Reading Theme in Server Components
  • Cloudflare Pages & Edge Deployment
  • Common Issues & Quick Fixes
  • Frequently Asked Questions

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

bash
npm install next-themes

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

    Add ThemeProvider to layout.tsx

    tsx โ€” app/layout.tsx
    import { 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>
      )
    }

    suppressHydrationWarning is required on the <html> element not <body>. The next-themes blocking script sets the class attribute 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.

    disableTransitionOnChange prevents 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. 2

    Understand the anti-flash script

    This is the critical piece most guides skip. When ThemeProvider renders, it automatically injects a tiny inline blocking script into <head>. That script runs synchronously before the browser paints anything, reads localStorage, 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 suppressHydrationWarning exists to handle. The script and the warning suppression are a matched pair: you need both.

  3. 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 have class="dark" or class="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 ThemeProvider is wrapping children directly inside layout.tsx, and that attribute="class" is set.

๐Ÿ’ก defaultTheme="system"

Setting defaultTheme="system" means first-time visitors (no localStorage entry yet) get whichever theme their OS prefers. This is the right default: users can override it with your toggle. next-themes handles prefers-color-scheme detection automatically.

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.

js โ€” tailwind.config.js
// v3 only: has no effect in Tailwind CSS v4
module.exports = {
  darkMode: 'class',
}
css โ€” app/globals.css
@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.

โš  Using Tailwind CSS v3?

If you're still on Tailwind v3, use darkMode: 'class' in tailwind.config.js instead. The CSS-based @custom-variant approach is v4 only.

Adding a Theme Toggle

tsx โ€” components/ThemeToggle.tsx
'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.

โ„น The aria-label is load-bearing

Screen readers announce the toggle button's purpose from its aria-label. The dynamic label (Switch to dark mode / Switch to light mode) tells users what will happen when they click which is more useful than just Toggle theme. This pattern is covered in more detail in our guide on ARIA in React.

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.

โ„น Keep theme-dependent UI client-side

Any component that renders differently based on the current theme such as toggle buttons, icon variants, theme-aware illustrations, should be a Client Component. Server Components can safely use dark: Tailwind utilities for styles, but cannot read the current theme value at render time.

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.

๐Ÿ’ก This is battle-tested

DevEncyclopedia runs Next.js App Router + Tailwind CSS v4 + Cloudflare Pages, and this is the exact implementation in production. No flash, no edge compatibility issues, no cookie complexity needed.

Common Issues & Quick Fixes

  • Flash still appears: confirm suppressHydrationWarning is on the <html> element, not <body>. Also confirm ThemeProvider wraps children in layout.tsx directly.
  • Dark mode utilities not applying (Tailwind v4): add the @custom-variant dark line to globals.css as shown above.
  • Toggle button flickers between states on load: add the mounted check before rendering the button.
  • Theme resets on every page load: check localStorage is accessible. Some browser privacy modes block it. next-themes handles this gracefully but the theme won't persist.
  • Hydration warning still showing: confirm suppressHydrationWarning is on the <html> element and that ThemeProvider uses attribute="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

nextjs

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.

May 30, 2026ยท7 min read
nextjs

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.

May 30, 2026ยท8 min read
react

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.

Jun 1, 2026ยท10 min read

On this page

  • Why the Flash Happens
  • Install next-themes
  • Wrap the App in ThemeProvider
  • Tailwind CSS v4: What Changed from v3
  • Adding a Theme Toggle
  • Reading Theme in Server Components
  • Cloudflare Pages & Edge Deployment
  • Common Issues & Quick Fixes
  • Frequently Asked Questions