DEV Community

Mark
Mark

Posted on

React 19 Hydration Mismatch in Static Export

📚 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>
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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,
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)