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.
Root Cause
The project started with next-themes:
// ❌ next-themes causes SSR/CSR mismatch
<ThemeProvider attribute="class" defaultTheme="dark">
<html>{children}</html>
</ThemeProvider>
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>
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>
[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; }
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>
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() };
Result: SPA navigation preserves theme ✅
Pitfall 3: Console SyntaxError
The Problem
Uncaught SyntaxError: missing ) after argument list at bmi-calculator/:1:4908
Root Cause
The <script> in <head> used dangerouslySetInnerHTML:
<script dangerouslySetInnerHTML={{ __html: `(function(){...&&...})()` }} />
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" />
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;
}
Three-layer priority:
-
:rootdefaults to light -
@media (prefers-color-scheme: dark)overrides for system preference -
[data-theme]with!importantforces 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;
}
: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 │
└─────────────────────────────────────────────┘
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){}
})();
app/[locale]/layout.tsx — Reference external JS:
<head>
<script src="/theme-init.js" />
</head>
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)
}
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; }
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:
-
dangerouslySetInnerHTMLin<head>is a trap — Next.js RSC serializes__html, turning&&into\u0026\u0026→ syntax error - External JS is one frame slower than inline — Requires HTTP request; first frame renders before JS executes
-
CSS
@mediais the only way to eliminate flash completely — Browser knows colors during CSS parsing, no JavaScript needed -
Tailwind v4's
darkModeconfig is unreliable —variantis ignored;dark:always binds to@media prefers-color-scheme -
Three-tier priority strategy —
:rootdefault →@mediafor system →[data-theme]for user choice -
SPA navigation clears
<html>classes — Must interceptpushState/replaceStateto 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)