Hydration Proof

Search documentation

Find a page or section

Fix the toLocaleString hydration mismatch

1,234.5 on the server, 1.234,5 in Berlin.

A toLocaleString hydration mismatch happens when a number or date is formatted without an explicit locale. The server formats with its own default (often en-US), the browser with the visitor's language, and "1,234.5" becomes "1.234,5" during hydration. Pass the same locale, from the URL or a cookie, to the formatter on both sides.

Symptoms

Hydration failed because the server rendered text didn't match the client.
Text content does not match server-rendered HTML.
Warning: Text content did not match. Server: "Total: 1,234,567.891" Client: "Total: 1.234.567,891"
Minified React error #418; visit https://react.dev/errors/418

A toLocaleTimeString hydration error looks the same with a time: "5:00:00 PM" on the server, "17:00:00" in the browser. React 19 lists this cause itself in the error message: "Date formatting in a user's locale which doesn't match the server."

hydration-proof reports it as HP1001 with the cause Locale-dependent formatting. It recognizes the same digits with different separators, and in the package's own test suite names this cause with 98% confidence.

Why a toLocaleString hydration mismatch happens

Every locale-sensitive API falls back to the runtime's default locale when you leave the locale out. On the server that is whatever the container sets (LANG, LC_ALL, or the Node.js build's default). In the browser it is the visitor's language. They rarely agree.

These use the default locale:

  • value.toLocaleString(), toLocaleDateString() and toLocaleTimeString()
  • a.localeCompare(b) without its second argument
  • Intl.NumberFormat, Intl.DateTimeFormat, Intl.RelativeTimeFormat, Intl.PluralRules, Intl.Collator, Intl.ListFormat and Intl.DisplayNames without a locale, or with undefined
  • Intl.NumberFormat().resolvedOptions().locale, which returns the default itself

Dates have a second problem on top of this one: the time zone. A date formatted with an explicit locale can still differ by the zone, covered in timezone differences.

How to fix it

Pass the locale from the request

The server knows the locale from the URL (/de/pricing), a cookie or the Accept-Language header. Pass it down, and use it on both sides:

app/[locale]/pricing/page.tsx
import { Price } from "./price";
 
export default async function Page({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  return <Price amount={1234.5} locale={locale} />;
}
app/[locale]/pricing/price.tsx
"use client";
 
export function Price({ amount, locale }: { amount: number; locale: string }) {
  // Before: amount.toLocaleString() used each runtime's default
  const text = amount.toLocaleString(locale, {
    style: "currency",
    currency: "EUR",
  });
  return <p>Total: {text}</p>;
}

Pin one locale where it does not matter

An admin tool, a log viewer or a fixed-market site can use one locale everywhere:

percent.tsx
const percent = new Intl.NumberFormat("en-US", { style: "percent" });
 
export function Percent({ value }: { value: number }) {
  return <span>{percent.format(value)}</span>;
}

The same goes for comparisons: a.localeCompare(b, "en"). A list sorted with localeCompare and no locale can come out in a different order on each side, which the require-deterministic-list-order rule reports.

Fix an i18n hydration error in next-intl

A next-intl hydration error almost always means the server and the client provider disagree about the locale, the time zone or "now". Configure all three once, in the request config:

src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
 
export default getRequestConfig(async () => ({
  locale: "en",
  // One zone for both sides. Read it from the user's profile or a cookie if it varies.
  timeZone: "Europe/Berlin",
  // One "now" for both sides, used by relative time formatting.
  now: new Date(),
}));

A NextIntlClientProvider rendered by a Server Component inherits the locale, messages, time zone, now and formats from that config, so Client Components format exactly like the server did. If you render the provider from a Client Component instead, pass locale and timeZone to it yourself. next-intl warns with ENVIRONMENT_FALLBACK when no time zone is configured; treat that warning as this bug. The next-intl guide to reliable date formatting covers the details.

Format after mount when only the browser knows

If the page must use the browser's own locale and the server cannot know it, render a neutral value first and format in an effect. See useEffect and two-pass rendering.

Catch it with ESLint

no-locale-without-explicit-locale reports every call above when the locale is missing, undefined or []. A variable is accepted (toLocaleString(locale)), because passing the locale from the server is the fix. The rule suggests the locale from its defaultLocale option (en-US by default) but never inserts it on its own: the right locale is a product decision.

npm install -D eslint-plugin-hydration-proof

Catch it in CI

The default scenario uses the machine's locale, and a server on the same machine renders with the same one. Add the locales your visitors use:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  matrix: {
    locale: ["en-US", "de-DE", "ar-EG"],
  },
});

A scenario's locale also sets the Accept-Language header, so a server that negotiates the locale sees the same one. Findings are HP1001, with the locale that separates failing runs from passing ones. With --probe, hydration-proof reloads the page with only the browser locale changed; if the value follows it, the locale is the proven cause. See probes.

npx hydration-proof test --probe