I've been building with Next.js for a while now, and I still get tripped up by hydration errors. Not because I don't understand what hydration is, but because the errors show up in so many different situations that it's hard to keep a clean mental model of what's going wrong and why.

Last month I spent the better part of an afternoon debugging one that turned out to be caused by a browser extension. Not my code. A Chrome extension injecting a data- attribute on the <html> tag. That's the kind of thing that makes you question your career choices.

So I figured I'd write down everything I actually know about hydration mismatches, partly for other people, mostly for future me.

What hydration actually is (quickly)

When you use SSR in React, your components render twice. First on the server, which produces HTML that gets sent to the browser. The user sees this immediately. Then React loads on the client side and "hydrates" that HTML, which means it walks through the existing DOM and attaches event handlers and state to what's already there. It doesn't tear the DOM down and rebuild it. It adopts it.

The contract is: the server HTML and the client's first render have to produce the same output. When they don't, React gets confused. It found DOM nodes that don't match what it expected, and now it has to figure out what to do. In React 18+ it'll try to recover from minor mismatches (text differences, attribute mismatches) but log a warning. For structural mismatches, like the server rendered a <div> and the client wants a <span>, it might throw away the entire server-rendered tree and re-render from scratch on the client.

That re-render defeats the whole point of SSR. Your user already saw the server HTML and now the page flickers as React rebuilds everything. Not great.

The causes I keep running into

Timezones

This one gets me every time. Server runs in UTC. Client runs in whatever timezone the user is in. If you format a date during render, the output will be different.

export default function EventTime({ timestamp }: { timestamp: string }) {
  const formatted = new Intl.DateTimeFormat("en-US", {
    dateStyle: "medium",
    timeStyle: "short",
  }).format(new Date(timestamp));
 
  return <span>{formatted}</span>;
}

Server renders "Mar 9, 2026, 3:00 PM" (UTC). Client renders "Mar 9, 2026, 10:00 AM" (EST). Mismatch.

I've hit this on probably four different projects now and I still sometimes forget to account for it when I'm moving fast.

Browser extensions

This is the one that cost me an afternoon. Extensions like Google Tag Assistant, password managers, ad blockers, and various dev tools inject attributes and sometimes entire DOM nodes into your page. React's hydration check sees those attributes and panics because it didn't put them there.

<html lang="en" data-tag-assistant-prod-present="pending:1774108549130">

You can't prevent this. You can't even reliably detect which extension did it. This is one of the few cases where suppressHydrationWarning is the right call.

The typeof window check

I see this pattern constantly and it always causes problems:

function MyComponent() {
  if (typeof window !== "undefined") {
    return <div>Client Only Content</div>;
  }
  return <div>Server Content</div>;
}

On the server, window is undefined, so it renders "Server Content." On the client, window exists, so it renders "Client Only Content." Two different outputs. Hydration mismatch.

The fix is to not branch during render. Use useEffect to detect the client and update state after the initial render.

Random values and IDs

This one's obvious once you think about it but it catches people:

function UniqueId() {
  return <div id={`item-${Math.random()}`}>Hello</div>;
}

Math.random() returns a different value on the server and client. React 18 added useId() specifically for this. Use it.

Invalid HTML nesting

Browsers are surprisingly opinionated about HTML structure. If you put a <div> inside a <p>, the browser will silently restructure the DOM to fix it. But React doesn't know about that restructuring. It hydrates against what it thinks the DOM should look like, which doesn't match what the browser actually created.

<p>
  <div>This looks fine but the browser rewrites it</div>
</p>

I found one of these buried in a component library I was using. The component rendered a <p> wrapper and I was passing in a child that had <div> elements. Took me a while to track down because the rendered page looked fine visually.

localStorage and client-only data

Anything you read from localStorage, sessionStorage, or client-side cookies during the initial render will cause a mismatch. The server doesn't have access to those values, so it renders without them. The client reads the stored value and renders something different.

How I actually fix these

The timezone fix that worked for us

We had a component showing event times that was causing hydration warnings for every user. The fix was to render the raw UTC string on the server and only format it to the user's timezone after hydration, in a useEffect:

"use client";
 
import { useEffect, useState } from "react";
 
export default function LocalTime({ utcTime, locale }: { utcTime: string; locale: string }) {
  const [formatted, setFormatted] = useState<string | null>(null);
 
  useEffect(() => {
    const date = new Date(utcTime);
    if (Number.isNaN(date.getTime())) return;
 
    setFormatted(
      new Intl.DateTimeFormat(locale, {
        year: "numeric",
        month: "short",
        day: "numeric",
        hour: "numeric",
        minute: "2-digit",
        timeZoneName: "short",
      }).format(date)
    );
  }, [utcTime, locale]);
 
  return <span>{formatted ?? utcTime}</span>;
}

The user sees the UTC time for a brief moment, then it updates to their local timezone. The flash is barely noticeable, and there's no mismatch because the server and client agree on the initial render.

suppressHydrationWarning (use sparingly)

<html lang="en" suppressHydrationWarning>

I put this on the root <html> element and that's it. It catches the browser extension noise without hiding real problems deeper in the component tree. I've seen codebases where people slap suppressHydrationWarning on half their components and then wonder why their UI has subtle bugs. Don't do that. It's a scalpel, not a bandage.

Dynamic imports for client-only stuff

If a component genuinely cannot render on the server (it depends on window, reads from local storage during init, uses a browser-only API), skip SSR for it entirely:

import dynamic from "next/dynamic";
 
const ClientOnlyTimer = dynamic(() => import("./Timer"), { ssr: false });

I use this for things like analytics dashboards and interactive widgets that pull from browser state. The tradeoff is that the component doesn't appear in the initial HTML, which can hurt perceived performance and SEO. For above-the-fold content, the useEffect approach is usually better.

Cookies for server-side awareness

This is the approach I like best for things like theme preferences. On first visit, the server doesn't know the user's preference. But if you set a cookie on the client side:

document.cookie = `timezone=${Intl.DateTimeFormat().resolvedOptions().timeZone}`;

Then on subsequent visits, the server can read that cookie and render the correct timezone from the start. No flash, no mismatch. The downside is the first visit still has the issue, but for repeat visitors (which is most of your traffic, usually) it works well.

import { cookies } from "next/headers";

My decision framework

After dealing with enough of these, I've settled into a pattern. Here's the quick reference version:

SituationApproach
Browser extensionssuppressHydrationWarning
Timezone displayuseEffect or cookies
localStorageuseEffect
Random valuesuseId
Client-only UIdynamic with ssr: false
Invalid HTMLFix the HTML
Logic mismatchRefactor the component

And in practice: if it's browser extensions or third-party scripts, I suppress the warning on the closest parent element and move on. There's nothing else to do.

For timezone or localStorage issues, I reach for the useEffect pattern. Render a safe default on the server, update on the client after mount. For timezones specifically, I'll add cookie-based detection for repeat visitors if the component is prominent enough.

If a component fundamentally can't work on the server, I use dynamic with ssr: false. No point fighting it.

Invalid HTML nesting? Fix the HTML. Non-negotiable. Browsers will keep restructuring the DOM and the hydration errors will keep coming until you do.

Random values get useId(). There's no reason not to.

And if I'm not sure what's causing it, I open the browser console, look at the specific warning React gives (in dev mode it usually tells you which element and what the difference is), and trace back from there. Most hydration bugs are annoying but shallow. They're usually in the last thing you changed.


References

  • hydrateRoot — React — React's official documentation on how hydration works, including how mismatches are detected and recovered from in React 18+.
  • useId — React — The hook React added specifically for generating stable IDs that match between server and client renders.
  • Lazy Loading — Next.js — Next.js documentation on dynamic imports with ssr: false for client-only components.
  • Content Categories — MDN Web Docs — MDN's reference on HTML content models, which explains why browsers silently restructure invalid nesting like <div> inside <p>.