DEV Community

Cover image for The Next.js Hydration Error Everyone Hits and How to Actually Fix It
Amrishkhan Sheik Abdullah
Amrishkhan Sheik Abdullah

Posted on

The Next.js Hydration Error Everyone Hits and How to Actually Fix It

This is the second article in Real Coding Problems, Simple Fixes.

The first article was about a React search box showing old results because API responses came back in the wrong order.

This one is a little harder.

We are going to talk about one of the most confusing errors people hit when moving from normal React to Next.js:

Hydration failed because the server rendered HTML didn't match the client.
Enter fullscreen mode Exit fullscreen mode

That error sounds serious. It also sounds like React is angry about something deep inside the framework.

Most of the time, the problem is much simpler:

The server rendered one version of the page, but the browser rendered a different version when React started.

Let's break that down with a real example.

The Real Problem

Imagine you are building a dashboard.

The dashboard has a theme toggle. If the user selected dark mode earlier, you store that choice in localStorage.

That feels normal:

theme = "dark"
Enter fullscreen mode Exit fullscreen mode

So you write a component that reads the saved theme and renders the button text.

"use client";

import { useState } from "react";

export default function ThemeToggle() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem("theme") || "light";
  });

  function toggleTheme() {
    const nextTheme = theme === "light" ? "dark" : "light";
    setTheme(nextTheme);
    localStorage.setItem("theme", nextTheme);
  }

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you are coming from a client-only React app, this may look fine.

But in Next.js, this can break.

You may see:

ReferenceError: localStorage is not defined
Enter fullscreen mode Exit fullscreen mode

or:

Hydration failed because the server rendered HTML didn't match the client.
Enter fullscreen mode Exit fullscreen mode

The confusing part is that the file already says:

"use client";
Enter fullscreen mode Exit fullscreen mode

So why is this still a problem?

Why "use client" Does Not Mean "Only Runs in the Browser"

This is the part that trips up a lot of developers.

In Next.js App Router, "use client" means the component is allowed to use browser-side React features like:

  • useState
  • useEffect
  • event handlers
  • browser interactions after the page loads

But the first HTML can still be prepared before the browser fully takes over.

That means your first render must be safe for both worlds:

  • the server
  • the browser

The server does not have:

window
document
localStorage
sessionStorage
matchMedia
Enter fullscreen mode Exit fullscreen mode

Those belong to the browser.

So if your render logic depends on them too early, Next.js can end up with different output on the server and the client.

That difference is what causes the hydration error.

What Hydration Means in Plain English

Think of Next.js like a restaurant preparing your table before you arrive.

The server sends ready-made HTML so the page appears quickly.

Then React loads in the browser and attaches all the interactive behavior:

  • clicks
  • state
  • event handlers
  • updates

That process is hydration.

For hydration to work cleanly, React expects the first browser render to match the HTML that came from the server.

If the server rendered:

<button>Current theme: light</button>
Enter fullscreen mode Exit fullscreen mode

but the browser immediately renders:

<button>Current theme: dark</button>
Enter fullscreen mode Exit fullscreen mode

React sees a mismatch.

That is the error.

The Bad Pattern

Here is the common mistake:

const [theme, setTheme] = useState(() => {
  return localStorage.getItem("theme") || "light";
});
Enter fullscreen mode Exit fullscreen mode

This reads localStorage while React is calculating the initial render.

That is too early.

The server cannot read localStorage, and even if you add checks like this:

const savedTheme =
  typeof window !== "undefined"
    ? localStorage.getItem("theme")
    : "light";
Enter fullscreen mode Exit fullscreen mode

you can still create different server and client output.

The server may render light.

The browser may instantly render dark.

Now the first HTML does not match.

Fix 1: Start With a Safe Default, Then Read Browser Storage

The safest beginner-friendly fix is:

  1. Render the same default value on the server and first client render.
  2. Read localStorage inside useEffect.
  3. Update the UI after the component has mounted.

Here is the fixed version.

"use client";

import { useEffect, useState } from "react";

export default function ThemeToggle() {
  const [theme, setTheme] = useState("light");
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    const savedTheme = localStorage.getItem("theme");

    if (savedTheme === "light" || savedTheme === "dark") {
      setTheme(savedTheme);
    }

    setIsReady(true);
  }, []);

  function toggleTheme() {
    const nextTheme = theme === "light" ? "dark" : "light";
    setTheme(nextTheme);
    localStorage.setItem("theme", nextTheme);
  }

  if (!isReady) {
    return <button disabled>Loading theme...</button>;
  }

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the first render is predictable.

The component starts with:

const [theme, setTheme] = useState("light");
Enter fullscreen mode Exit fullscreen mode

Then, after the browser is ready, useEffect reads localStorage.

Why does this work?

Because useEffect only runs in the browser after React has rendered.

So the server is no longer asked to read something it does not have.

Fix 2: Create a Reusable useMounted Hook

If you do this in multiple components, repeating isReady everywhere gets boring.

You can create a small hook:

import { useEffect, useState } from "react";

export function useMounted() {
  const [mounted, setMounted] = useState(false);

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

  return mounted;
}
Enter fullscreen mode Exit fullscreen mode

Then use it like this:

"use client";

import { useEffect, useState } from "react";
import { useMounted } from "./useMounted";

export default function ThemeToggle() {
  const mounted = useMounted();
  const [theme, setTheme] = useState("light");

  useEffect(() => {
    if (!mounted) return;

    const savedTheme = localStorage.getItem("theme");

    if (savedTheme === "light" || savedTheme === "dark") {
      setTheme(savedTheme);
    }
  }, [mounted]);

  function toggleTheme() {
    const nextTheme = theme === "light" ? "dark" : "light";
    setTheme(nextTheme);
    localStorage.setItem("theme", nextTheme);
  }

  if (!mounted) {
    return <button disabled>Loading theme...</button>;
  }

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The hook makes the intent clear:

Do not show browser-dependent UI until the component is mounted in the browser.

This is useful for theme, cart count, persisted filters, saved sidebar state, or anything else stored in the browser.

Fix 3: Build a Small useLocalStorageValue Hook

For a real project, I prefer making storage access explicit and reusable.

Here is a simple hook:

import { useEffect, useState } from "react";

export function useLocalStorageValue(key, defaultValue) {
  const [value, setValue] = useState(defaultValue);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const storedValue = localStorage.getItem(key);

    if (storedValue !== null) {
      setValue(storedValue);
    }

    setReady(true);
  }, [key]);

  function updateValue(nextValue) {
    setValue(nextValue);
    localStorage.setItem(key, nextValue);
  }

  return { value, setValue: updateValue, ready };
}
Enter fullscreen mode Exit fullscreen mode

Now the component becomes smaller:

"use client";

import { useLocalStorageValue } from "./useLocalStorageValue";

export default function ThemeToggle() {
  const {
    value: theme,
    setValue: setTheme,
    ready,
  } = useLocalStorageValue("theme", "light");

  function toggleTheme() {
    const nextTheme = theme === "light" ? "dark" : "light";
    setTheme(nextTheme);
  }

  if (!ready) {
    return <button disabled>Loading theme...</button>;
  }

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is not a perfect hook for every storage case. For example, it only stores strings.

But the pattern is the important part:

  • use a safe default
  • read browser storage after mount
  • avoid changing the first render unexpectedly

Fix 4: Use dynamic With ssr: false for Browser-Only Widgets

Sometimes a component is completely browser-only.

Examples:

  • a chart library that needs window
  • a map widget
  • a rich text editor
  • a component that depends heavily on browser APIs

For those cases, you can tell Next.js not to server-render that component.

"use client";

import dynamic from "next/dynamic";

const BrowserOnlyChart = dynamic(() => import("./BrowserOnlyChart"), {
  ssr: false,
});

export default function AnalyticsPage() {
  return (
    <main>
      <h1>Analytics</h1>
      <BrowserOnlyChart />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

This can be the right fix when the component simply cannot produce meaningful HTML on the server.

In the App Router, keep this dynamic usage inside a Client Component file. If you try to use ssr: false directly inside a Server Component, Next.js will complain.

But do not use it everywhere.

If you disable server rendering for too much of your page, you lose some of the benefits of Next.js:

  • faster first HTML
  • better SEO for meaningful content
  • less useful server-rendered output

Use it when the component is truly browser-only.

What About suppressHydrationWarning?

Next.js also gives you an escape hatch:

<span suppressHydrationWarning>
  {new Date().toLocaleTimeString()}
</span>
Enter fullscreen mode Exit fullscreen mode

This tells React:

I know this content may be different. Do not warn me for this specific element.

This can be fine for small unavoidable differences, like a timestamp.

But it should not be your first fix for normal app state.

If your cart count, theme, auth UI, or dashboard content is mismatching, hiding the warning does not fix the actual problem. It only makes the warning quieter.

Most of the time, you should fix the render flow instead.

A Simple Debugging Checklist

When you see a hydration error, ask these questions:

  1. Am I reading window, document, or localStorage during render?
  2. Am I rendering Date.now() or Math.random() directly in JSX?
  3. Does the server render one value while the browser immediately renders another?
  4. Is my first render dependent on auth, theme, cart, or browser storage?
  5. Is a third-party component using browser APIs before mount?
  6. Is my HTML valid, or do I have tags nested incorrectly?

The goal is simple:

The server HTML and the first client render should match.

After that first render, React can update the UI normally.

The Mental Model

Here is the easiest way to remember it:

Server render:
Should not depend on browser-only data.

First client render:
Should match what the server rendered.

After mount:
Safe to read localStorage, window, document, and other browser APIs.
Enter fullscreen mode Exit fullscreen mode

If you keep that order in your head, hydration errors become much less mysterious.

Final Takeaway

Hydration errors are not random.

They usually happen because your first browser render does not match the HTML that came from the server.

For localStorage, the practical fix is:

Start with a safe default
+ Read localStorage inside useEffect
+ Render browser-only UI after mount
+ Use dynamic imports only for truly browser-only components
+ Avoid suppressHydrationWarning unless you really mean it
Enter fullscreen mode Exit fullscreen mode

Once you understand that, the error message becomes less scary.

It is just React saying:

I expected the first client render to match the server HTML, but it did not.

And now you know where to look.

References

Top comments (0)