VOOZH about

URL: https://dev.to/utlkit/react-19-hydration-mismatch-in-static-export-4kpn

⇱ React 19 Hydration Mismatch in Static Export - DEV Community


📚 This is Part 3 of the UtlKit Tech Series
Part 2 covers the architecture & trade-offs → Read Part 2

React 19 Static Export Hydration Mismatch? Pitfalls in Next.js 15

I built a 150+-tool online site with Next.js 15, deployed on Cloudflare Pages.
Everything looked great until the console started flooding with Hydration Error #418.

The Problem

Open DevTools Console — a wall of red:

Error: Hydration failed because the server rendered HTML didn't match the client.
Expected server HTML: <html class="dark">
Client-rendered HTML: <html>

The site works fine functionally, but this error hurts SEO scores and floods Sentry.

Weirder still: npm run dev works fine. npm run build && npm start breaks.

Root Cause Analysis

Layer 1: React 19 RSC and Static Export Compatibility

Next.js 15 defaults to React 19 with React Server Components (RSC). But in output: 'export' mode, every page is statically rendered to HTML.

The problem:

  1. SSR phase: Server-side render tries to read localStorage (theme value) → can't read it (no localStorage on server)
  2. CSR phase: Client-side hydration reads localStorage → has a value, dynamically adds class="dark"
  3. Result: SSR HTML ≠ CSR HTML → Hydration Mismatch

Layer 2: Dynamic className on <html> and <body>

The layout.tsx had code like this:

// ❌ Causes Hydration Mismatch
export default function RootLayout({ children }) {
 const theme = useTheme() // Client hook reading localStorage
 return (
 <html lang="zh" className={theme}>
 <body>{children}</body>
 </html>
 )
}

useTheme() returns the default value (light) during SSR, but returns the localStorage value (possibly dark) during CSR. Different class on <html> → Hydration Error.

Layer 3: Terser Made Things Worse

To reduce bundle size, I added Terser minification to the build:

// next.config.js
compress: true,

After Terser compression, code execution timing changed subtly, making hydration behave inconsistently across browsers (especially Chrome). Sometimes it hydrates fine, sometimes it errors — non-deterministic bugs are the worst.

Solutions (Progressive)

Solution 1: suppressHydrationWarning (Quick Fix)

// ✅ Tells React to ignore differences on <html>
<html lang="zh" className={theme} suppressHydrationWarning>

Result: Errors gone ✅
Problem: This masks the issue. Dark mode still flashes on first paint — white then black.

Solution 2: Head Script to Pre-read Theme (Eliminates Flash)

Add a <script> in <head> that executes before React renders:

<script>
 (function() {
 const theme = localStorage.getItem('theme') || 'light';
 document.documentElement.classList.add(theme);
 document.documentElement.style.colorScheme = theme;
 })();
</script>

Result: No first-paint flash ✅, suppressHydrationWarning clears errors ✅
Problem: Good enough, but I wanted to try React 19...

Solution 3: Downgrade React 19 → 18 (Root Fix)

After testing, React 19's RSC has several known issues with output: 'export':

  • Different hydration timing than React 18
  • Some Server Component features behave unexpectedly during static export
  • Many community reports (GitHub Issues)

Downgraded decisively:

//package.json{"dependencies":{"react":"^18.3.1","react-dom":"^18.3.1"}}

Result: All Hydration issues completely resolved ✅
Problem: Lost RSC, but static sites don't need it anyway.

Final Solution (What's in Production)

// app/layout.tsx
export default function RootLayout({ children }) {
 return (
 <html lang="zh" suppressHydrationWarning>
 <head>
 <script dangerouslySetInnerHTML={{ __html: `
 (function() {
 try {
 var t = localStorage.getItem('theme') || 'light';
 document.documentElement.classList.add(t);
 document.documentElement.style.colorScheme = t;
 } catch(e) {}
 })();
 `}} />
 </head>
 <body>{children}</body>
 </html>
 )
}

Key points:

  1. <script> in <head> executes before React → eliminates flash
  2. suppressHydrationWarning → eliminates errors
  3. try/catch → prevents localStorage errors in restricted environments (iframes, etc.)
  4. React 18 → stable hydration timing

Lessons Learned

Approach Effect Recommendation
suppressHydrationWarning only Clears errors, but flash remains ⭐⭐
Head script + suppressHydrationWarning No errors, no flash ⭐⭐⭐⭐
React 19→18 downgrade + head script Most stable ⭐⭐⭐⭐⭐
useLayoutEffect delayed DOM Works but complex ⭐⭐⭐

Core takeaways:

  1. Static sites don't need RSC — RSC brings 10x more problems than benefits in output: 'export' mode
  2. localStorage is the #1 cause of Hydration mismatches — SSR can't read it, CSR can, guaranteed inconsistency
  3. Head script is the best pattern for theme/locale flash — Zero dependencies, zero overhead
  4. Don't over-optimize builds — Terser saved a few KB but introduced non-deterministic bugs

Project

This site is UtlKit — 150+ free online developer tools. All the issues above were encountered during real development and deployment, and the solutions are running stably in production.


If this helped, feel free to leave a ❤️. Questions welcome in the comments.