This scenario sits under the Hydration Mismatch & State Recovery guide, which itself belongs to the broader Session State Persistence & Hydration Fallbacks coverage of client-side durability patterns.

The exact failure this page solves #

A Next.js or Vite-based React app boots cleanly in development, then throws a red overlay in production: Error: Text content does not match server-rendered HTML, followed by Hydration failed because the server rendered text didn't match the client and a link to react.dev/errors/418 or react.dev/errors/425. The page still renders — React discards the server markup for the affected subtree and re-renders it client-side — but users see a visible flash, and if the mismatch cascades into a parent boundary, the app can fall back to a full client-side re-render of a much larger tree, hurting both the largest contentful paint metric and interaction latency.

The root cause is always the same shape: the string React embedded in the HTML sent from the server does not equal the string the client computes on its first render pass. React does not retry or reconcile character-by-character; it treats the entire text node as wrong and swaps it out. This page walks through the concrete sources of that divergence and the two fixes that resolve it correctly — a mounted-flag placeholder pattern, and a narrowly scoped suppressHydrationWarning. It does not cover the general architecture of hydration fallbacks (see the parent guide for that), nor date/locale-specific suppression strategy in depth (covered in Suppressing Hydration Warnings for Dates and Locales Safely).

Six causes account for nearly every occurrence of this error:

  • Date.now() / new Date() evaluated at render time. The server renders at request time; the client renders milliseconds (or, with cached HTML, seconds) later. Any literal timestamp printed into text content will differ.
  • Math.random() or other non-deterministic values used directly in JSX text, such as a randomly assigned A/B variant label or a generated id printed as visible text.
  • Locale- or timezone-dependent formattingtoLocaleString(), toLocaleDateString(), or Intl.NumberFormat — where the server’s runtime locale/timezone (often en-US / UTC in a container) differs from the visitor’s browser locale.
  • typeof window !== 'undefined' branches that render different text on the server (where window is undefined) versus the client, e.g. showing “Loading…” server-side and a real value client-side.
  • Browser-only API reads such as navigator.userAgent, localStorage.getItem(...), or screen.width interpolated directly into rendered text — these simply don’t exist during server rendering.
  • Invalid HTML nesting, such as a <div> inside a <p> or a <tr> without a <table> ancestor. The browser’s HTML parser silently repairs the DOM structure during the initial parse, producing a tree shape that differs from what React’s virtual DOM expected to attach to, which surfaces as a text-content mismatch on the nodes around the repair point.

Zero-to-working implementation #

The block below shows the buggy version first, then the fixed version using a useMounted hook, then the narrow-suppression alternative for content that is supposed to differ.

// --- BEFORE: mismatches on every request ---
function LastVisit() {
  // Server computes this at request time; client computes it moments later.
  // The two strings almost never match -> error #418/#425.
  return <p>Last visit: {new Date().toLocaleString()}</p>;
}

// --- AFTER: deterministic placeholder, then upgrade post-mount ---
import { useEffect, useState } from 'react';

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

  useEffect(() => {
    // Effects never run during server rendering, so this only fires
    // in the browser, immediately after the first commit succeeds.
    setMounted(true);
  }, []);

  return mounted;
}

function LastVisit() {
  const mounted = useMounted();

  if (!mounted) {
    // Server render AND the client's first render both hit this branch,
    // so the text content is byte-for-byte identical -> no mismatch.
    return <p>Last visit: —</p>;
  }

  // This branch only runs after hydration has already committed cleanly,
  // so reading Date.now() / locale here is safe.
  return <p>Last visit: {new Date().toLocaleString()}</p>;
}

// --- ALTERNATIVE: narrow suppression for a leaf that's meant to differ ---
function RelativeTime({ iso }: { iso: string }) {
  return (
    // suppressHydrationWarning only silences the mismatch warning for this
    // element's own direct text child — it does not protect descendants.
    <time dateTime={iso} suppressHydrationWarning>
      {formatRelative(iso)}
    </time>
  );
}

Notice that the fixed version does not simply move the Date read into useEffect and call it done — the render function itself still returns a <p> on every pass. What changes is which string that <p> contains, gated by a value (mounted) that is provably identical between the server render and the client’s first render. That’s the entire trick: React only compares what gets rendered, so make the first two renders render the same thing on purpose.

Why the mismatch survives one render cycle #

The diagram below traces a single request through both fixes side by side — the mounted-flag path on the left, the narrow-suppression path on the right — to show exactly where each one intercepts the divergence before React ever compares text.

Two fixes for a hydration text mismatch Left column: server render and client first render both produce a static placeholder because of the mounted flag, so React finds matching text, then a post-mount render upgrades to the real value. Right column: the leaf node is allowed to differ because suppressHydrationWarning is applied directly to it, one level deep, so React skips the warning for that node only. Mounted-flag path Narrow suppression path Server render: "—" Client first render: "—" mounted=true → real value no mismatch — text matched twice first Server render: "3h ago" Client render: "3h 1m ago" suppressHydrationWarning on the <time> leaf only difference declared expected

Step-by-step explanation #

  1. Locate the diverging node. Open the page with JavaScript disabled or view curl output, and compare it against the hydrated DOM in DevTools. The first text node that differs is your target — don’t assume it’s the outermost component the error points to.

  2. Classify the cause. Match the diverging value against the six causes above: is it a Date, a random value, a locale format, a typeof window branch, a browser-only API, or a parser-repaired invalid nesting? The fix differs by category, so this step determines whether you reach for useMounted or a structural HTML fix.

  3. Add a mounted flag. Implement useMounted() exactly as shown — useState(false) plus a zero-dependency useEffect that flips it to true. Because effects are a client-only, post-commit phase, this flag is guaranteed false during both the server render and the client’s very first render.

  4. Render a deterministic placeholder while !mounted. The placeholder must be a static string with no runtime-dependent input — an em dash, a skeleton, or a fixed label. This guarantees the server and the client’s first pass produce identical markup, so React has nothing to complain about.

  5. Upgrade to the real value once mounted is true. This second render only happens client-side, after hydration has already succeeded, so it’s free to use Date.now(), locale APIs, localStorage, or anything else that isn’t available during server rendering.

  6. For content that’s supposed to differ, scope suppressHydrationWarning to the single leaf element — never a wrapping <div> — and only when the difference is cosmetic (a timestamp, a relative-time string) rather than structural.

Edge cases #

The table below covers the traps that don’t disappear just because you’ve adopted useMounted — each one comes from a different layer (React’s own prop contract, the runtime environment, third-party code you don’t control, or the HTML parser itself), so each needs its own mitigation rather than a single blanket fix.

Scenario Symptom Mitigation
suppressHydrationWarning on a wrapper <div> Warning disappears for text, but nested elements still mismatch The prop only suppresses the warning for that element’s own direct text child, one level deep — apply it to the innermost <span>/<time> that actually varies, not an ancestor
Locale/timezone formatting (toLocaleString, Intl.*) Server (container UTC/en-US) and browser locale disagree Format on the client only, behind useMounted, or pass an explicit locale/timeZone option that’s identical on both sides instead of relying on runtime defaults
Third-party script injects DOM before hydration Mismatch on a node React didn’t expect to have children Move the script to next/script with strategy="afterInteractive" (or equivalent deferred load) so it runs after hydration, and never let it target a node inside a React-managed tree
Invalid HTML nesting (<div> in <p>, <tr> without <table>) Mismatch reported on siblings of the invalid node, not the node itself Run the markup through an HTML validator or React’s dev-mode console warning for “cannot appear as a child of”; fix the tag choice rather than chasing the reported text node

Verification steps #

  1. Check the console in development. React’s dev build prints the actual diverging text — the before/after strings and a component stack — directly beneath the hydration warning. Confirm the warning is gone entirely, not merely quieter.

  2. Rebuild and serve a production bundle. Development mode is more forgiving about timing; run next build && next start (or your framework’s equivalent) and reload with a hard refresh to confirm the fix holds under production’s error codes #418/#425, not just the verbose dev message.

  3. Diff server vs. client HTML directly. Fetch the route with curl -s https://your-app/route and compare the relevant text node against the same node inspected in DevTools after hydration — they should be byte-identical for anything not behind useMounted.

  4. Disable JavaScript and reload. The placeholder state (the !mounted branch) is what a no-JS visitor and search crawlers will actually see, so confirm it’s a sensible, non-broken fallback, not just a blank string.

  5. Watch for a layout shift when the mounted upgrade swaps in the real value — a Lighthouse CLS regression at the same DOM node you just fixed is a sign the placeholder should reserve the same visual space as the final content.

  6. Grep the codebase for repeat offenders. Once you’ve fixed one instance, search for other calls to Date.now(), Math.random(), toLocaleString(), and typeof window inside JSX return statements — these are the same six root causes recurring in different components, and each one needs the identical useMounted treatment or a scoped suppressHydrationWarning, not a one-off patch.

Frequently asked questions #

Why does this error only show up in production builds sometimes?

Development builds print a verbose diff of the mismatched text, while production builds collapse it to minified error codes #418 and #425 with a link to the decoder. The underlying cause is identical; production just hides the diagnostic detail unless you decode the digest.

Is suppressHydrationWarning a safe permanent fix?

Only for content that is genuinely expected to differ, such as a rendered timestamp or relative-time string, and only on the single leaf element that varies. It silences the warning for one DOM node’s direct text and does not propagate to children, so it must never be used to mask a structural mismatch.

Why did wrapping my component in useEffect not fix the mismatch?

useEffect only runs after the client has already committed its first render, which is the render React compares against the server markup. Skipping the render entirely with an early return inside the effect does not help; you need a mounted flag that changes what gets rendered, forcing a matching first pass followed by a controlled second pass.