Hydration Mismatch After Locale Switch in Next.js

A “Text content did not match. Server: … Client: …” hydration warning after a locale switch almost always means the server rendered one locale and the client resolved a different one — a stale NEXT_LOCALE cookie disagreeing with the URL prefix, or Intl formatting that differs between the Node runtime and the browser. React renders the server HTML, then re-renders on the client; when the two strings diverge, React discards the server markup for that subtree and logs the mismatch. This page isolates the four causes that produce it after a language change — cookie-versus-URL disagreement, Intl output differing across runtimes, Date.now()/timezone drift, and object key-order in message lookups — then shows the single-source-of-truth fix and where suppressHydrationWarning is and is not appropriate.

The bug surfaces downstream of the routing layer described in Next.js i18n Routing Setup; a misconfigured locale middleware is the most common upstream trigger.

Locale-switch hydration mismatch sequence The server reads a stale cookie and renders German, but the client reads the URL prefix and re-renders English. React compares the two strings, finds a mismatch, and discards the server markup. Server render reads stale cookie "Hallo" Client hydrate reads URL prefix /en/ "Hello" React diff "Hallo" ≠ "Hello" mismatch Discard SSR re-render subtree + console warning
Two locale sources disagree: the server renders from a stale cookie, the client re-renders from the URL prefix.

Root cause analysis

React hydration is a string comparison. The server serializes a tree to HTML; on the client, React calls hydrateRoot, re-renders the same components, and expects the produced markup to match the server’s byte-for-byte for text nodes and attributes. When it does not, React (18+) keeps the server HTML but throws away the mismatched subtree and re-renders it on the client, emitting Warning: Text content did not match. The visible effect is a flash of the wrong language followed by a correction, plus a noisy console.

Four distinct causes produce this specifically after a locale switch:

  1. Stale cookie versus URL prefix. A language switcher updates the NEXT_LOCALE cookie but navigates client-side without a full reload, or the middleware sets the cookie on a redirect that the server render already passed. The server component reads the old cookie value while the client component reads the new URL segment (/en/…). Two locale sources, two render outputs. This is why a single, ordered locale source — URL prefix first, as in locale negotiation strategies — matters: whichever signal the server trusts, the client must trust the same one.

  2. Intl output differing across runtimes. Intl.NumberFormat, Intl.DateTimeFormat, and Intl.RelativeTimeFormat resolve against the host’s ICU/CLDR data. A Node server built against one ICU version can emit a different grouping separator, currency symbol position, or non-breaking-space variant (U+00A0 vs U+202F before “€”) than the browser’s ICU. The strings look identical but differ by one code point, and hydration fails. This is the same CLDR-version sensitivity covered in date and number formatting standards.

  3. Date.now() and timezone drift. Rendering “2 minutes ago” or new Date().toLocaleString(locale) evaluates the clock twice — once on the server, once milliseconds later on the client — and in two timezones (server UTC, client local). Relative times and any wall-clock value that crosses a unit boundary between the two renders mismatch.

  4. Message key order / non-deterministic lookup. Spreading a messages object whose key order differs between server and client bundles, or selecting a plural branch from CLDR categories that resolve differently per runtime, yields different text. Less common, but it appears when messages are merged from multiple sources in a Map/object whose iteration order is not pinned.

Minimal reproducible example

The smallest trigger reads the locale from two different places in the same component. The server resolves it from the cookie; the client resolves it from window.location.

// app/[lang]/greeting.tsx — BROKEN: two locale sources
'use client';
import { cookies } from 'next/headers'; // server-only, but bundled intent shown

const MESSAGES: Record<string, string> = { en: 'Hello', de: 'Hallo' };

export function Greeting({ serverLocale }: { serverLocale: string }) {
  // Server passed serverLocale from the NEXT_LOCALE cookie ("de").
  // On the client we "helpfully" re-derive from the URL ("en").
  const clientLocale =
    typeof window !== 'undefined'
      ? window.location.pathname.split('/')[1] // "en"
      : serverLocale; // "de"
  return <h1>{MESSAGES[clientLocale]}</h1>;
}

Switch from German to English, and the server emits <h1>Hallo</h1> (stale cookie) while the client hydrates <h1>Hello</h1> (URL), producing:

Warning: Text content did not match. Server: "Hallo" Client: "Hello"

The Intl variant is even subtler — identical code, divergent data:

// Mismatches only when server and client ICU disagree on the spacing/symbol.
const price = new Intl.NumberFormat('de-DE', {
  style: 'currency',
  currency: 'EUR',
}).format(1234.5); // server: "1.234,50 €" with U+00A0, client: U+202F → mismatch

The fix: one locale source, deterministic formatting

Resolve the locale exactly once, on the server, from the URL segment — the authoritative signal — and pass it down as a prop. The client never re-derives it. Pre-format Intl output on the server (or pin the runtime data) so the string the client receives is already final, and render time-dependent values from a server-fixed timestamp.

// app/[lang]/layout.tsx — SERVER component resolves the single source of truth
import { Greeting } from './greeting';

const SUPPORTED = ['en', 'de'] as const;

export default async function LangLayout({
  params,
}: {
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  // The URL prefix is the ONLY locale source. No cookie read at render time.
  const locale = (SUPPORTED as readonly string[]).includes(lang) ? lang : 'en';

  // Pre-format on the server so the client receives a finished string,
  // not an Intl call it must re-run against possibly-different ICU data.
  const price = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: 'EUR',
  }).format(1234.5);

  return <Greeting locale={locale} formattedPrice={price} />;
}
// app/[lang]/greeting.tsx — CLIENT trusts the prop, never re-derives
'use client';

const MESSAGES: Record<string, string> = { en: 'Hello', de: 'Hallo' };

export function Greeting({
  locale,
  formattedPrice,
}: {
  locale: string;
  formattedPrice: string; // already formatted on the server — no client Intl
}) {
  // No window.location, no cookie, no re-formatting: the server string wins.
  return (
    <section>
      <h1>{MESSAGES[locale]}</h1>
      <p>{formattedPrice}</p>
    </section>
  );
}

For genuinely client-only, time-dependent text — a live “x minutes ago” that must differ from the server — opt that one node out instead of silencing the whole tree:

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

export function RelativeTime({ iso }: { iso: string }) {
  // Render the server-stable absolute date first; upgrade after mount.
  const [label, setLabel] = useState<string | null>(null);
  useEffect(() => {
    const mins = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
    setLabel(new Intl.RelativeTimeFormat('en').format(-mins, 'minute'));
  }, [iso]);
  // suppressHydrationWarning is scoped to THIS node only — not the page.
  return <time dateTime={iso} suppressHydrationWarning>{label ?? iso}</time>;
}

suppressHydrationWarning belongs only on the single element whose content is legitimately non-deterministic (clocks, random ids). It does not fix a locale mismatch — using it to hide a stale-cookie bug just ships the wrong language to first paint.

Verification

Assert that the server-resolved locale and the rendered text agree, with no console.error from React. A React Testing Library render plus a console spy proves the warning is gone:

// greeting.test.tsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './greeting';

test('renders the prop locale with no hydration warning', () => {
  const errors: string[] = [];
  const spy = jest
    .spyOn(console, 'error')
    .mockImplementation((m) => errors.push(String(m)));

  render(<Greeting locale="de" formattedPrice="1.234,50 €" />);

  expect(screen.getByRole('heading')).toHaveTextContent('Hallo');
  expect(errors.join('\n')).not.toMatch(/did not match|hydrat/i);
  spy.mockRestore();
});

To catch the Intl runtime-data divergence before it ships, pin and assert ICU at build time so the server cannot format differently from a known-good baseline:

# Node built with full ICU returns formatted CLDR data; "en" only = data drift risk
node -e "console.log(new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(1234.5))"
# Expect: 1.234,50 €   (verify the space before € is consistent across environments)
node -e "console.log(process.versions.icu)"   # pin this value in CI

When to escalate

This single-source fix resolves the overwhelming majority of locale-switch hydration warnings. It is insufficient when the mismatch persists despite identical locale props — which points at runtime data drift, not your code: a server and client built against different ICU/CLDR versions (common with --with-intl=small-icu Node builds, or an edge runtime whose Intl data lags the browser). In that case, ship full-icu, format all locale-sensitive values on the server, or move formatting behind a useEffect so it never runs during hydration. If the warning only appears under a translation-management sync where keys arrive in different orders per build, pin message-object iteration order at the bundling step. For the routing and locale-resolution model these failures grow out of, return to Next.js i18n Routing Setup, and confirm the upstream resolver matches the middleware configuration the client trusts.

FAQ

Why does Next.js log “Text content did not match” only after I switch languages?

Because the switch creates two disagreeing locale sources: the server renders from one signal (often a stale NEXT_LOCALE cookie) while the client re-derives from another (the new URL prefix). React compares the server HTML to the client re-render, finds different text, discards the server subtree, and logs the warning. Resolve the locale once on the server from the URL and pass it down as a prop so both renders use the same value.

Should I just wrap everything in suppressHydrationWarning to stop the error?

No. suppressHydrationWarning only silences the warning for the single element it is on; it does not reconcile the two renders, so the user still sees the wrong language at first paint. Reserve it for genuinely non-deterministic single nodes such as live clocks or random ids, and fix locale mismatches at the source by using one authoritative locale signal.

My locale prop is identical on both sides but Intl still mismatches — why?

The server and client are formatting against different ICU/CLDR data versions, so the same Intl.NumberFormat or DateTimeFormat call emits a different separator, currency-symbol spacing (U+00A0 vs U+202F), or grouping. Pre-format the value on the server and pass the finished string to the client, ship Node with full-icu, and pin process.versions.icu in CI.

Part of Next.js i18n Routing Setup.