Errors and fixes

Hydration mismatch from dates: the server runs in UTC, the browser does not

The same date formatted on the server and in the browser gives different strings when the zone or the locale change. Format it once on the server.

Key points

  • `toLocaleString()` without an explicit time zone returns different output on the server and in the browser.
  • Format on the server and pass the string, or pin `timeZone: "UTC"` in both contexts.
  • `suppressHydrationWarning` on `

Short answer: the server formats the date in UTC, the browser formats it in the user's time zone. The two strings differ and React regenerates the tree. Format once, on the server, and pass the finished string.

Where the difference is born

In SSR the server ships HTML with the text already inside. During hydration React runs the component again, builds the tree in memory and compares node by node. When the text differs, React treats the boundary as broken and regenerates that subtree in the client: the server DOM is thrown away, together with field state and the render work.

Three variables meet on a date, and the third is the sneaky one. You know the zone and the locale; you do not know the ICU version. Node and the browser can carry different CLDR data and produce different separators for the same instant, for example a narrow no-break space before AM. The string looks identical on screen and differs by one code point.

Why dates are the worst case

A date is the result of three variables: the timestamp, the time zone and the locale. The timestamp is the same; the other two are not. The server almost always runs in UTC and in English, while the browser uses the system zone and language.

new Date("2026-11-01T09:00:00Z").toLocaleString()
// server (UTC, en-US):    "11/1/2026, 9:00:00 AM"
// client (Europe/Rome):   "01/11/2026, 10:00:00"

The preferred fix: format on the server

// app/orders/[id]/page.tsx — Server Component
const DF = new Intl.DateTimeFormat("it-IT", {
  dateStyle: "long",
  timeZone: "Europe/Rome",
});

export default async function Page({ params }) {
  const order = await getOrder(params.id);
  return (
    
      {DF.format(new Date(order.createdAt))}
    
  );
}

The string arrives already formatted, so the client recalculates nothing. The time zone is explicit and does not depend on the machine that runs the render.

The fix when the time zone must be the user's

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

export function LocalDate({ iso }: { iso: string }) {
  const [label, setLabel] = useState(null);
  useEffect(() => setLabel(new Date(iso).toLocaleString()), [iso]);
  return (
    
      {label ?? "—"}
    
  );
}

The first render shows a dash on the server and on the client, so they match. The local date arrives after mount. The suppressHydrationWarning only covers the edge case where the browser hydrates later than expected.

The diagnostic step

The React message already carries both strings: find it in the console and compare the code points, not the visible characters.

const iso = "2026-11-01T09:00:00Z";
const f = (tz) => new Intl.DateTimeFormat("it-IT", { dateStyle: "long", timeStyle: "short", timeZone: tz }).format(new Date(iso));
for (const v of [f("UTC"), f("Europe/Rome")]) console.log(JSON.stringify(v), [...v].map((c) => c.codePointAt(0).toString(16)).join(" "));

Run the same block in Node and in the browser console: if the sequences diverge under the same zone, the problem is the localisation data, not your code. Aligning the ICU version across the two environments, or dropping client-side formatting, is the only clean route.

When the fix does not work

If the mismatch stays, look for other sources of variation on the same page: Math.random(), Date.now(), a counter, an id generated without useId. The date is often just the first symptom. A second case is a browser extension that rewrites the text: check in an incognito window.

When the common fix makes things worse

suppressHydrationWarning is the first line you find when searching the error. It silences the warning one nesting level deep and leaves the server DOM in the page: the user sees the server time until a state update replaces it, and the defect stays invisible in the logs. On a <time> element that is acceptable because the value really does change after mount; on a wrapper that holds other text it also hides the genuine mismatches.

The other fix that turns up often pins the zone to UTC so the strings agree. The error disappears and the result is wrong for every user outside Greenwich: an order placed at ten in the morning shows as eight. A visible mismatch beats a silently wrong date.

One limitation to budget for

The server cannot know the user zone on the first request. The only hint in the header is Accept-Language, which states the language, not the zone: two Italian speakers may sit in Rome and in São Paulo. With static HTML or a CDN cache there is no personalisation on the first byte, and the local date needs a second pass in the client. The leading dash is not a cosmetic choice, it is the one thing server and client can draw identically.

How to prevent it

  • One shared formatter, with an explicit timeZone.
  • In the markup use <time dateTime>: the machine reads the ISO value, the person reads the string.
  • An automated test with a browser time zone that differs from the server one.

Where to go next

In the catalogue: components, data table and blocks. The guides: hydration failed: causes and fixes and the App Router structure; the comparison: dark mode without a second design system.

Frequently asked questions

Why doesn't the build flag the problem?

The mismatch comes from comparing the server HTML with the client render, and the compiler has no two environments to compare.

Is UTC everywhere enough?

For a technical timestamp yes. For a date shown to a person no, because the user expects their own zone. Format it on the client after mount.

Does <time dateTime> solve it?

No, but it is the right shape: the attribute carries the machine-readable date and the text carries the human-readable one.

How do I test the case?

Set the browser time zone to a value different from the server one and open the page: the mismatch appears at once.

Does it apply to numbers?

Yes, thousands and decimal separators change with the locale. The rule is the same: format once.