DEV Community

locionic
locionic

Posted on Originally published at locionic.com

Next.js Hydration Errors: The 5 Villains Breaking Your SSR (And How to Fix Them)

I still remember the first time a giant red hydration overlay hijacked my browser console.

The stack trace pointed to an internal minified React bundle, the error message read like an ancient riddle ("Text content does not match server-rendered HTML"), and for a split second, my entire layout flashed unstyled before popping into place.

My initial thought was that our API was broken. It wasn't. I had simply tried to format a dynamic timezone string directly inside a component.

If you build with Next.js (or any SSR React framework), hydration errors are a rite of passage. But once you understand the underlying engine, they stop being mysterious.

Here are the 5 most common culprits I see break hydration in production codebases — and the clean patterns to solve each one.


What Actually Happens During "Hydration"?

Before jumping into fixes, here is the mental model in 30 seconds:

  1. On the server (Node.js): Next.js runs your components, turns them into static HTML, and sends it down the wire. The user sees pixels almost instantly, but buttons can't click yet.
  2. In the browser (Client): React downloads the JavaScript bundle, walks the server-rendered HTML tree, and attaches event listeners (onClick, onChange) to bring the static DOM alive.

Here is the golden rule: The HTML rendered on the server must match the HTML generated on the client's first render pass down to the exact character.

Phase Where It Runs Output Primary Goal
Pre-rendering Node.js Server Raw HTML & CSS Instant visual paint (FCP)
Hydration User Browser Event-bound DOM Interactive page (TTI)

If there is even a single mismatch — a different timestamp, an extra class, or an attribute that only exists in the browser — React bails out. It throws Error #418 (or a hydration warning), throws away the server HTML, and rebuilds that entire component tree on the client.

Your initial paint benefit? Destroyed. Your CPU? Spiked.

Let’s look at the villains causing this.


1. Accessing window or localStorage During Render

The server has no window, no document, and no localStorage. The most common mistake is reading browser APIs directly in your render path:

// ❌ FAILS: window is undefined on the server, causes immediate mismatch
export function ResponsiveNav() {
  const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
  return <nav className={isMobile ? 'mobile' : 'desktop'}>...</nav>;
}
Enter fullscreen mode Exit fullscreen mode

Even with typeof window !== 'undefined', the server evaluates to false (desktop), but the client evaluates to true (mobile). Instant hydration crash.

The Fix: Defer Client State to useEffect

useEffect only executes in the browser after hydration is complete. Use it to synchronize browser-only state safely:

// ✅ SAFE: Server and initial client render match identically
import { useState, useEffect } from 'react';

export function ResponsiveNav() {
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    setIsMobile(window.innerWidth < 768);
  }, []);

  return <nav className={isMobile ? 'mobile' : 'desktop'}>...</nav>;
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: If data lives only in the browser, initialize it with a predictable server-safe fallback and update it inside useEffect.


2. Dynamic Timestamps, Dates, and Random Numbers

If you display the current time or generate random IDs in your render logic, the server runs at timestamp $T_1$, and the client runs hundreds of milliseconds later at $T_2$:

// ❌ Server: "10:00:00 AM" | Client: "10:00:01 AM"
export function LastUpdated() {
  return <span>Updated: {new Date().toLocaleTimeString()}</span>;
}
Enter fullscreen mode Exit fullscreen mode

The Fix: Mounted State Guard or suppressHydrationWarning

For dynamic dates that update on mount, use a mounted boolean:

// ✅ Option A: Wait for mount
export function LastUpdated() {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return <span>Updated: Loading...</span>; // Matches server HTML
  }

  return <span>Updated: {new Date().toLocaleTimeString()}</span>;
}
Enter fullscreen mode Exit fullscreen mode

If the timestamp is static and a slight server/client timezone difference doesn't impact your UI logic, you can tell React to intentionally ignore differences on that specific element:

// ✅ Option B: One-level suppression for static dates
<span suppressHydrationWarning>{new Date().toLocaleDateString()}</span>
Enter fullscreen mode Exit fullscreen mode

(Note: suppressHydrationWarning only works one level deep. Don't slap it on <body> to silence your errors!)


3. Invalid HTML Tag Nesting (The Sneakiest Culprit)

This one drives developers crazy because there's zero JavaScript state involved.

If you write invalid HTML, modern browser parsers automatically "repair" the DOM before React's JavaScript even loads.

<!-- ❌ Invalid HTML: The browser automatically closes <p> before opening <div> -->
<p>
  Welcome back!
  <div>Here is your dashboard preview</div>
</p>
Enter fullscreen mode Exit fullscreen mode

When Chrome parses that snippet, it rewrites the DOM into:

<p>Welcome back!</p>
<div>Here is your dashboard preview</div>
<p></p>
Enter fullscreen mode Exit fullscreen mode

When React tries to hydrate the server-rendered <p> tag, it finds a <div> sibling instead of a child. React panics: "Hydration failed because the initial UI does not match what was rendered on the server."

Common HTML nesting traps:

  • Putting <div>, <p>, <ul>, or <form> inside <p>
  • Putting <tr> directly inside <table> without a <tbody>
  • Putting block-level elements inside <a> or <span>

Run your markup through a linter or check the Elements panel in Chrome to see if the browser altered your tags.


4. The next-themes Dark Mode Flash

If you use next-themes or custom theme providers, you've likely seen this error. The server renders the default theme (e.g. light), but the user's browser has dark stored in localStorage.

The client immediately attempts to mount <html class="dark">, conflicting with the server's <html class="light">.

The Fix:

Add suppressHydrationWarning directly to your root <html> tag in app/layout.tsx:

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

next-themes injects an inline script to update the class before paint. Setting suppressHydrationWarning on <html> is officially recommended by the Next.js team because this specific attribute mismatch is intentional.


5. Third-Party Extensions Mutating the DOM

You spent 3 hours debugging a hydration error only to find out it only reproduces on your laptop. Why?

Browser extensions (Google Translate, Grammarly, password managers, ad blockers) frequently inject nodes or attributes into the DOM before hydration finishes:

  • Grammarly wraps text nodes in <grammarly-extension> tags.
  • Google Translate changes text and adds font tags.
  • Password managers inject SVG icons into <input> fields.

The Fix: Test in Incognito First

Whenever you hit a weird hydration error with no obvious code cause, open an Incognito window with all extensions disabled. If the error vanishes, your code is fine — an extension was polluting the DOM.

To prevent translation extensions from breaking text node hydration across your entire app, add this meta tag to your <head>:

<meta name="google" content="notranslate" />
Enter fullscreen mode Exit fullscreen mode

Summary & Quick Debugging Checklist

When a hydration warning pops up in your terminal or browser:

  1. Check the red overlay: Next.js 14+ displays a character-level diff showing Server rendered vs Client rendered. Look for theme or timestamp differences.
  2. Reproduce in Incognito: Rule out browser extensions before touching your codebase.
  3. Inspect tag nesting: Check if you placed block elements inside <p> or tables without <tbody>.
  4. Move browser APIs into useEffect: Never read window, document, or localStorage during initial render.

This guide was adapted from our deep-dive technical breakdown on locionic.com. If you want to see interactive hydration diff playgrounds and test your knowledge with interactive quizzes, check out the full article!

Top comments (0)