Skip to main content
Logo
Overview

Next.js 16 Hydration Errors: Causes and Fixes (React 19)

July 7, 2026
10 min read

You upgraded to Next.js 16, ran your app, and now the whole page is red. Hydration failed because the server rendered HTML didn't match the client. The same code worked fine yesterday on Next.js 15. Welcome to the club.

Here’s the thing nobody tells you before the upgrade: your app probably had hydration bugs all along. Next.js 16 ships React 19, and React 19 stopped being polite about them. What used to be a yellow warning in the console — easy to ignore, easy to ship — is now a thrown error that blanks the screen. On top of that, Partial Prerendering (PPR) is on by default now, and it streams a static shell before filling in the dynamic parts, which drags mismatches into the light that used to hide in the noise.

So the good news and the bad news are the same news. The errors were always there. Now you have to fix them. This is a walk through what actually causes a Next.js hydration error, a diagnostic that takes under a minute, and the fix for each cause — including the sixth one the official docs don’t list.

Why the error is louder now

Hydration is the moment React takes the HTML the server sent, walks the same component tree on the client, and wires up event handlers. It assumes the two renders produce identical markup. If the server said <span>10:42 PM</span> and the client says <span>10:43 PM</span>, the trees diverge and React throws its hands up.

React 18 would log a warning and patch over the difference by re-rendering on the client. Sloppy, but survivable. React 19 treats a mismatch as a correctness bug — because it is — and errors out. The official Next.js error page lists the usual suspects, but the framing matters: you didn’t write new bugs, React just stopped hiding your old ones.

PPR makes it worse in a specific way. With prerendering, Next.js serves a cached static shell instantly, then streams the dynamic holes. If a dynamic value ended up baked into the static shell — a timestamp, a user greeting, anything non-deterministic — the streamed version won’t match the shell, and you get a mismatch that never showed up in the old fully-dynamic render path. Some teams flip PPR off to make the pain stop. That’s treating the symptom. The mismatch was real; PPR just found it.

The six real causes

Five of these are in the docs. The sixth eats an afternoon before you figure it out.

1. Dates and times rendered on the server

The single most common cause. You render new Date().toLocaleTimeString() directly in a component. The server renders it at request time, the client re-renders it a few hundred milliseconds later, and the seconds have ticked. Mismatch.

// This will bite you
function Clock() {
  return <span>{new Date().toLocaleTimeString()}</span>;
}

Same trap with relative time (“2 minutes ago”), countdown timers, and anything derived from the current moment. The server’s “now” and the client’s “now” are never the same instant.

2. Browser-only APIs during render

window, document, localStorage, navigator — none of these exist on the server. When you read them in the render body, the server either crashes or renders a fallback, and the client renders the real thing. Two different trees.

// undefined on the server, a real value on the client
function Sidebar() {
  const collapsed = localStorage.getItem("sidebar") === "collapsed";
  return <aside className={collapsed ? "collapsed" : ""}>...</aside>;
}

3. Random values as keys or IDs

Math.random(), crypto.randomUUID(), or Date.now() used to generate an id or key in render. The server picks one number, the client picks another, and every attribute built from it mismatches. Component libraries that generate their own IDs the naive way are a frequent culprit here.

4. Locale and timezone differences

Your server runs in UTC. Your user’s browser runs in America/Chicago. You format a number with toLocaleString() or a date without pinning the timezone, and the two environments disagree on the decimal separator, the currency symbol, or the hour. This one is sneaky because it passes every test on your machine — you and your CI runner happen to share a locale — and only breaks for users in other regions. One writeup traced a persistent double-render straight back to a timezone offset.

5. Invalid HTML nesting

A <div> inside a <p>. A <p> inside another <p>. A <table> without a <tbody>. The browser silently “fixes” invalid nesting by moving elements around, so the DOM the client hydrates against no longer matches what the server serialized. React sees the reshuffled tree and reports a mismatch, even though your data is perfectly deterministic. This is the one people stare at for twenty minutes because the values look identical — the structure is what changed.

6. The stale .next cache (the one the docs skip)

You’ve checked all five. Your source is clean. The error persists anyway. Check your build cache.

During development, Fast Refresh updates the client bundle in place while the dev server can keep streaming an older server render. The two fall out of sync and you get a mismatch that has nothing to do with your code — it’s an artifact of the cache. It’s becoming one of the most common causes in App Router projects, precisely because it looks exactly like a real bug and no amount of reading your components will reveal it.

rm -rf .next
npm run dev

If the error vanishes, it was the cache. If you ever burn an hour on a hydration error where the source genuinely looks correct, do this first, not last.

The 60-second diagnostic

Before you start guessing, narrow it down. React 19’s error overlay shows a diff — the expected server text versus what the client rendered. Grab the server-rendered string from that diff and search your source for it.

# The overlay says the server rendered "10:42:07 PM"
grep -rn "toLocaleTimeString\|toLocaleString\|new Date\|Math.random\|localStorage\|window\." src/

Three outcomes, and each points somewhere different:

  • The string traces to a date, random, or browser API in a component. That’s causes 1–3. You found it.
  • The string exists but sits inside weird HTML nesting — a block element inside a <p>, a stray <div> in a table. That’s cause 5. Fix the markup.
  • The string isn’t in your source at all, or the code looks obviously correct. That’s cause 6. rm -rf .next before you read another line.

That last branch is the time-saver. Most guides send you re-reading your components for an error that lives in the cache. Rule out the cache with one command and you never waste the afternoon.

Fixing each cause

The pattern for most of these is the same idea: don’t render non-deterministic values on the server at all. Render a stable placeholder, then fill in the real value after mount, when only the client is running.

The mount guard for dates and browser APIs

The useEffect + mounted pattern is the workhorse. Effects run only on the client, after hydration, so anything inside them can’t cause a mismatch.

"use client";
import { useState, useEffect } from "react";
 
function Clock() {
  const [time, setTime] = useState(null);
 
  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
    const id = setInterval(() => setTime(new Date().toLocaleTimeString()), 1000);
    return () => clearInterval(id);
  }, []);
 
  // Server and first client render both produce this — they match
  return <span>{time ?? "--:--:--"}</span>;
}

The server renders --:--:--, the client’s first pass renders --:--:--, they match, hydration succeeds, and then the effect swaps in the live time. Same pattern for localStorage: initialize state to the server-safe default, read the real value in the effect.

useId for IDs, never Math.random

If you need a stable unique ID for an htmlFor/id pairing or an ARIA attribute, that’s exactly what useId exists for. React guarantees it produces the same value on server and client.

import { useId } from "react";
 
function Field({ label }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </>
  );
}

One caveat if you’re mixing versions: React 19.2 changed the default useId prefix. If you have two copies of React resolving in the same tree — easy to do in a monorepo mid-migration — the prefixes disagree and you get, ironically, an ID mismatch. Dedupe React to a single version.

suppressHydrationWarning — only when the mismatch is correct

There’s a narrow case where the server and client are supposed to differ and you can’t avoid it: an unavoidable timestamp, or content a browser extension injects into your markup (Grammarly, Colorzilla, and password managers all do this). For those, suppressHydrationWarning tells React to skip the check on that one element.

<time suppressHydrationWarning>{new Date().toISOString()}</time>

Two hard rules. It only works one level deep — it silences the element it’s on, not its children. And it is not a fix for real bugs. If you slap it on a component to make the red screen go away without understanding why the trees differ, you’ve hidden a correctness bug that will resurface as wrong data in production. Use it as an escape hatch for genuinely-external causes, nothing else. The React team is blunt about this, and they’re right.

Suspense boundaries for streamed dynamic data

Under PPR, wrap anything genuinely dynamic in a <Suspense> boundary with a fallback. That tells Next.js to keep it out of the static shell and stream it into a designated hole, so there’s no shell-versus-stream mismatch to begin with.

import { Suspense } from "react";
 
export default function Page() {
  return (
    <main>
      <StaticHeader />
      <Suspense fallback={<Skeleton />}>
        <LiveDashboard />   {/* dynamic — streamed, not baked into the shell */}
      </Suspense>
    </main>
  );
}

This is the fix for the PPR-specific mismatches, and it’s a better answer than disabling PPR wholesale. You keep the instant static shell for everything that’s actually static, and you draw a clean line around what isn’t.

A prevention checklist

Fixing the current error is half the job. Keeping the next one out of production is the other half.

  • Build and run production locally before you push. next build --turbo && next start exercises the real SSR + hydration path. next dev is more forgiving and will let mismatches slip through that start won’t.
  • Test in a non-UTC timezone and a non-default locale. Set TZ=Asia/Tokyo and a different LANG in one CI job. This is the cheapest way to catch cause 4 before a user in another region does.
  • Turn on the ESLint rules. react-hooks/rules-of-hooks and the Next.js plugin catch a chunk of the browser-API-in-render mistakes at lint time, before they ever reach a browser.
  • Pin your dates. If a timestamp has to render server-side, format it with an explicit timezone (Intl.DateTimeFormat with a fixed timeZone) so both environments agree. Don’t leave it to the ambient locale.
  • Keep one React version. Run npm ls react after any dependency change. Duplicates cause the subtle useId prefix mismatches that look impossible to debug.

None of this makes hydration errors impossible. But it moves them from “3am production incident” to “caught in CI,” which is the only move that actually matters.

If you’re staring at a red screen right now: read the overlay diff, grep for the string, and if the code looks clean, rm -rf .next before you doubt your sanity. Nine times out of ten it’s a date, a browser API, or the cache — in that order.