DEV Community

Mark
Mark

Posted on

Next.js 15 Dark Mode Without the Flash: From Hydration Error to Zero-Flicker

I built a 100+ tool website with Next.js 15 on Cloudflare Pages.
I thought implementing dark mode would take half a day. It took three, and I hit 8 different bugs along the way.

The Problem List

# Problem Status
1 React Hydration Error #418 ✅ Fixed
2 White screen flash on load ✅ Fixed
3 SyntaxError: missing ) after argument list ✅ Fixed
4 Toggle icon never changes ✅ Fixed
5 Theme lost after SPA navigation ✅ Fixed

Each fix introduced a new problem. The final solution looks simple, but the path to get there was anything but.

Pitfall 1: React Hydration Error #418

The Problem

Chrome DevTools filled with red:

Error: Hydration failed because the server HTML didn't match the client.
Enter fullscreen mode Exit fullscreen mode

Root Cause

The project started with next-themes:

// ❌ next-themes causes SSR/CSR mismatch
<ThemeProvider attribute="class" defaultTheme="dark">
  <html>{children}</html>
</ThemeProvider>
Enter fullscreen mode Exit fullscreen mode

next-themes makes SSR render HTML without a theme class, but CSR adds class="dark". React 19 reports Hydration Error.

The toggle button was also a problem:

// ❌ Conditional rendering → text mismatch between SSR and CSR
<button onClick={toggle}>
  {isDark ? '☀️' : '🌙'}
</button>
Enter fullscreen mode Exit fullscreen mode

SSR renders ☀️ (default dark). CSR renders 🌙 (if user's system is light). Different text → Error #418.

Fix

Drop next-themes. Use CSS to toggle icon visibility instead of conditional rendering — both icons always render in the DOM:

// ✅ Both icons always rendered, CSS controls visibility
<span className="theme-toggle-light-icon">🌙</span>
<span className="theme-toggle-dark-icon">☀️</span>
Enter fullscreen mode Exit fullscreen mode
[data-theme="light"] .theme-toggle-light-icon { display: inline; }
[data-theme="light"] .theme-toggle-dark-icon { display: none; }
[data-theme="dark"] .theme-toggle-light-icon { display: none; }
[data-theme="dark"] .theme-toggle-dark-icon { display: inline; }
Enter fullscreen mode Exit fullscreen mode

SSR and CSR text content is identical (🌙☀️), only CSS display differs. No hydration mismatch.

Pitfall 2: White Screen Flash

The Problem

Page flashes white before switching to dark mode.

First Fix: head script

<script>
  (function(){
    var s = localStorage.getItem('theme') || 'dark';
    document.documentElement.classList.add(s);
  })();
</script>
Enter fullscreen mode Exit fullscreen mode

Result: Flash gone ✅
New problem: Theme lost after SPA navigation

Second Fix: Intercept pushState

var p = history.pushState;
history.pushState = function(){ p.apply(this, arguments); restoreTheme() };
Enter fullscreen mode Exit fullscreen mode

Result: SPA navigation preserves theme ✅

Pitfall 3: Console SyntaxError

The Problem

Uncaught SyntaxError: missing ) after argument list at bmi-calculator/:1:4908
Enter fullscreen mode Exit fullscreen mode

Root Cause

The <script> in <head> used dangerouslySetInnerHTML:

<script dangerouslySetInnerHTML={{ __html: `(function(){...&&...})()` }} />
Enter fullscreen mode Exit fullscreen mode

Next.js 15's RSC (React Server Components) serializes the __html content into RSC payload data. During serialization, && gets escaped to \u0026\u0026, causing a syntax error when the client executes it.

The inline HTML script is fine — but the RSC payload copy is broken.

Fix: Move to an External JS File

// ❌ Serialized by RSC → SyntaxError
<script dangerouslySetInnerHTML={{ __html: '...' }} />

// ✅ External file, immune to RSC serialization
<script src="/theme-init.js" />
Enter fullscreen mode Exit fullscreen mode

Result: SyntaxError gone ✅
New problem: External JS requires an HTTP request → the first frame renders before JS executes → flash returns

Pitfall 4: External JS Still Flashes

Root Cause

<script src="/theme-init.js"> needs the browser to send an HTTP request, download the file, then execute it. Meanwhile the browser has already rendered the first frame using the default light CSS.

Fix: CSS @media (prefers-color-scheme) as the default

Core idea: Make the browser render the correct theme without any JavaScript.

/* Default: light */
:root {
  --bg: #f8fafc;
  --surface: #ffffff;
  --border: #e2e8f0;
  --text: #1e293b;
}

/* System prefers dark: @media overrides */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f172a;
    --surface: #1e293b;
    --border: #334155;
    --text: #e2e8f0;
  }
}

/* User explicitly chose a theme: data-theme overrides @media */
html[data-theme="dark"] {
  --bg: #0f172a !important;
  --surface: #1e293b !important;
  --border: #334155 !important;
  --text: #e2e8f0 !important;
}

html[data-theme="light"] {
  --bg: #f8fafc !important;
  --surface: #ffffff !important;
  --border: #e2e8f0 !important;
  --text: #1e293b !important;
}
Enter fullscreen mode Exit fullscreen mode

Three-layer priority:

  1. :root defaults to light
  2. @media (prefers-color-scheme: dark) overrides for system preference
  3. [data-theme] with !important forces user's explicit choice

The browser knows the colors while parsing CSS — no JavaScript needed for the first frame.

Pitfall 5: Toggle Icon Never Changes

The Problem

Theme switching works (the page actually changes), but the button always shows 🌙.

Root Cause

Tailwind CSS's dark: prefix (like dark:hidden) with darkMode: 'class' requires a .dark class. But Tailwind v4 ignores the variant configuration — dark: is always bound to @media (prefers-color-scheme: dark).

So dark:hidden only follows the system setting, not the JS-set data-theme. If the user's system is light but they manually switch to dark, dark:hidden still doesn't trigger.

Fix: Custom CSS Classes

Skip Tailwind's dark: prefix entirely:

[data-theme="light"] .theme-toggle-light-icon,
:not([data-theme]) .theme-toggle-light-icon {
  display: inline;
}
[data-theme="light"] .theme-toggle-dark-icon,
:not([data-theme]) .theme-toggle-dark-icon {
  display: none;
}
[data-theme="dark"] .theme-toggle-light-icon {
  display: none;
}
[data-theme="dark"] .theme-toggle-dark-icon {
  display: inline;
}
Enter fullscreen mode Exit fullscreen mode

:not([data-theme]) covers the default state when the user hasn't made a choice (default light, show 🌙).

Final Architecture

┌─────────────────────────────────────────────┐
│ Layer 1: CSS @media (prefers-color-scheme)  │
│  Native browser support, zero JS, correct   │
│  on the very first frame                    │
├─────────────────────────────────────────────┤
│ Layer 2: theme-init.js sets data-theme      │
│  Reads localStorage, overrides @media       │
│  Intercepts pushState for SPA navigation    │
├─────────────────────────────────────────────┤
│ Layer 3: ThemeProvider (React)              │
│  Syncs React state, handles user toggle     │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Files

public/theme-init.js — Runs before React:

(function(){
  try{
    var d = document.documentElement;
    var stored = localStorage.getItem('theme');
    var isDark;
    if(stored === 'light') isDark = false;
    else if(stored === 'dark') isDark = true;
    else isDark = window.matchMedia('(prefers-color-scheme:dark)').matches;

    if(isDark) d.setAttribute('data-theme', 'dark');
    else d.setAttribute('data-theme', 'light');

    // Restore after SPA navigation
    function restore(){
      if(!d.getAttribute('data-theme')){
        // Re-read and set...
      }
    }
    var p = history.pushState;
    history.pushState = function(){
      p.apply(this, arguments);
      requestAnimationFrame(restore);
    };
    // Same for replaceState and popstate...
  }catch(e){}
})();
Enter fullscreen mode Exit fullscreen mode

app/[locale]/layout.tsx — Reference external JS:

<head>
  <script src="/theme-init.js" />
</head>
Enter fullscreen mode Exit fullscreen mode

components/ThemeProvider.tsx — React state management:

function setTheme(theme: 'light' | 'dark' | 'system') {
  const resolved = theme === 'system' ? getSystemTheme() : theme
  localStorage.setItem(THEME_KEY, theme)
  const el = document.documentElement
  el.setAttribute('data-theme', resolved)
  el.classList.remove('dark', 'light')
  el.classList.add(resolved)
}
Enter fullscreen mode Exit fullscreen mode

globals.css — Three-tier CSS variables + toggle icons:

/* Default light */
:root { --bg: #f8fafc; --text: #1e293b; ... }

/* System dark override */
@media (prefers-color-scheme: dark) {
  :root { --bg: #0f172a; --text: #e2e8f0; ... }
}

/* User explicit choice forces override */
html[data-theme="dark"] { --bg: #0f172a !important; ... }
html[data-theme="light"] { --bg: #f8fafc !important; ... }

/* Toggle button icons */
[data-theme="dark"] .theme-toggle-light-icon { display: none; }
[data-theme="dark"] .theme-toggle-dark-icon { display: inline; }
[data-theme="light"] .theme-toggle-light-icon { display: inline; }
[data-theme="light"] .theme-toggle-dark-icon { display: none; }
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

Approach Result
next-themes ❌ Hydration Error #418
dangerouslySetInnerHTML in <head> ❌ RSC serialization SyntaxError
<script src> external file ⚠️ HTTP request → still flashes one frame
Tailwind dark: prefix ⚠️ v4 always binds to media query, ignores JS
@media default + data-theme override ✅ Perfect

Key takeaways:

  1. dangerouslySetInnerHTML in <head> is a trap — Next.js RSC serializes __html, turning && into \u0026\u0026 → syntax error
  2. External JS is one frame slower than inline — Requires HTTP request; first frame renders before JS executes
  3. CSS @media is the only way to eliminate flash completely — Browser knows colors during CSS parsing, no JavaScript needed
  4. Tailwind v4's darkMode config is unreliablevariant is ignored; dark: always binds to @media prefers-color-scheme
  5. Three-tier priority strategy:root default → @media for system → [data-theme] for user choice
  6. SPA navigation clears <html> classes — Must intercept pushState/replaceState to restore theme

Project

UtlKit — 150+ free online tools. All the above problems were encountered during real development and deployment. Solutions are running in production.


If this helped, feel free to drop a ⭐️. Comments welcome.

Top comments (0)