How to Fix Next.js Hydration Mismatch in App Router

nextjs hydration mismatch app router server components react

A Next.js hydration mismatch (the "Text content does not match server-rendered HTML" error) means the HTML your server rendered differs from what React produces on the client during hydration. The fix depends on the cause: browser-injected elements, non-deterministic rendering (dates, random IDs), or (most commonly in App Router) reading client-only values like localStorage, window, or Date.now() during a Client Component's initial render. The architectural solution is to fetch state on the server in a Server Component and pass it as props to a Client Component provider, so both server and client start with identical data.

Cause 1: Client-Only Values During Initial Render

This is the most frequent trigger. Any call to localStorage, navigator, window.innerWidth, or new Date() produces different output on the server than on the client. Wrapping everything in useEffect isn't the right fix because that causes a flash of incorrect content. Instead, use suppressHydrationWarning for truly dynamic leaf nodes, or defer the client-only value behind a mounted check only for values that genuinely can't be known on the server.

"use client";
import { useState, useEffect } from "react";

export function LocalTime({ serverTimestamp }: { serverTimestamp: string }) {
  // Start with the server-provided value so hydration matches.
  const [display, setDisplay] = useState(serverTimestamp);

  useEffect(() => {
    // After hydration, switch to the client's locale-formatted time.
    setDisplay(new Date(serverTimestamp).toLocaleTimeString());
  }, [serverTimestamp]);

  return <time dateTime={serverTimestamp}>{display}</time>;
}

Cause 2: Invalid HTML Nesting

React's hydration algorithm is strict about DOM structure. A <p> wrapping a <div>, or a <table> missing <tbody>, produces a mismatch because the browser auto-corrects the DOM before React hydrates. Inspect the rendered HTML in DevTools. If the browser moved or closed a tag, that's your culprit. Fix the markup; there's no workaround.

SSR State Pattern: Server Fetch to Client Provider

In the App Router, the pattern that eliminates most hydration issues is straightforward: fetch data in a Server Component, then pass it as a serializable prop to a Client Component provider. This guarantees that the server and client render from identical initial state. The following example shows the complete pattern for a user context.

// app/layout.tsx  (Server Component — no "use client")
import { cookies } from "next/headers";
import { UserProvider } from "@/components/user-provider";
import { getUser } from "@/lib/auth";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const token = cookieStore.get("session")?.value;
  // Fetch on the server. Returns null if unauthenticated.
  const user = token ? await getUser(token) : null;

  return (
    <html lang="en">
      <body>
        <UserProvider initialUser={user}>{children}</UserProvider>
      </body>
    </html>
  );
}
// components/user-provider.tsx
"use client";
import { createContext, useContext, useReducer, type ReactNode } from "react";
import type { User } from "@/lib/auth";

type Action = { type: "SET_USER"; user: User | null } | { type: "LOGOUT" };
type State = { user: User | null };

const UserContext = createContext<{ state: State; dispatch: React.Dispatch<Action> } | null>(null);

function reducer(state: State, action: Action): State {
  if (action.type === "SET_USER") return { user: action.user };
  if (action.type === "LOGOUT") return { user: null };
  return state;
}

export function UserProvider({ initialUser, children }: { initialUser: User | null; children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { user: initialUser });
  return <UserContext.Provider value={{ state, dispatch }}>{children}</UserContext.Provider>;
}
// Hook to consume user context safely.
export function useUser() {
  const ctx = useContext(UserContext);
  if (!ctx) {
    throw new Error("useUser must be used inside <UserProvider>");
  }
  return ctx;
}

Why This Pattern Eliminates Hydration Mismatches

Because initialUser is serialized into the RSC payload, the Client Component's useReducer initializes with the exact same value on both server and client. There's no useEffect fetch that flashes a loading state, and no localStorage.getItem that returns null on the server but a token on the client. The server and client HTML are byte-identical.

This pattern also solves the "useReducer null error" that you hit when you initialize context with undefined and try to read state.user.name during render. By typing the initial state as User | null and passing it from the server, you force null-checking at the call site.

Common Gotchas and Pitfalls

Don't read localStorage in useState's initializer. This runs on the server too (where localStorage is undefined), and even with a guard like typeof window !== "undefined", the server returns one value while the client returns another. That's a classic mismatch.

Third-party scripts inject DOM nodes. Browser extensions and analytics scripts add <div> or <style> tags between server render and hydration. Add suppressHydrationWarning to your <html> and <body> tags (Next.js already does this in newer versions). This isn't a code bug; it's environmental noise.

Math.random() and crypto.randomUUID() differ per call. If you use them to generate keys or IDs during render, generate the value once on the server and pass it down. Alternatively, use React's useId() hook, which is designed to stay stable across server and client.

// Safe unique IDs that match across server and client.
"use client";
import { useId } from "react";

export function EmailField() {
  // useId produces the same value on server and client.
  const fieldId = useId();
  return (
    <div>
      <label htmlFor={fieldId}>Email</label>
      <input id={fieldId} type="email" name="email" />
    </div>
  );
}

When You Genuinely Need Client-Only State

Sometimes you must show something that only the client knows: a theme preference from localStorage, viewport dimensions, or auth tokens from cookies that aren't available in the server function. The correct pattern is a two-pass render. Render a placeholder (or nothing) on the server, then update in useEffect. To avoid layout shift, render a skeleton that matches the final dimensions.

"use client";
import { useState, useEffect } from "react";

export function ThemeToggle() {
  // Render nothing theme-specific on the server.
  const [theme, setTheme] = useState<"light" | "dark" | null>(null);

  useEffect(() => {
    // Safe to read localStorage after hydration.
    const stored = localStorage.getItem("theme") as "light" | "dark" | null;
    setTheme(stored ?? "light");
  }, []);

  // While theme is null, render a neutral placeholder to avoid layout shift.
  if (theme === null) return <div className="theme-toggle-skeleton" />;

  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      {theme === "light" ? "🌙" : "☀️"}
    </button>
  );
}

Decision Tree Summary for Hydration Fixes

If the data exists on the server (database, cookies, headers), fetch it in a Server Component and pass it as props. If the data is client-only but needs to appear in the first paint, use suppressHydrationWarning on the specific leaf element. If the data is client-only and can wait one frame, read it in useEffect and render a skeleton first. Following these three rules eliminates virtually every hydration mismatch in the App Router.

← Back to all articles