Next.js hydration mismatch caused by toLocaleDateString on server and browser
Order dates rendered fine locally but threw hydration errors for users in Asia and Europe. The server formatted dates in UTC and en-US, the browser in local time. Here is how we found it and fixed it for good.
A few days after we shipped a redesigned order history page, Sentry started reporting a burst of React errors from production. Nothing was visibly broken for most of us in the office, but customer support had two tickets from users in Singapore and Berlin saying the page "flashed" and sometimes showed yesterday's date for an order they placed this morning.
The error in the browser console was React 19's hydration message:
Uncaught Error: Hydration failed because the server rendered text didn't match the client.
As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client
Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
...
+ 14.1.2025
- 1/13/2025
The diff at the bottom of the message was the real clue: the server sent 1/13/2025, the browser
(German locale, Berlin time) wanted to render 14.1.2025. Same order, same timestamp, two different strings.
Reproducing it
We could not reproduce it on our laptops in Ho Chi Minh City at first, because our local dev server and the browser shared the same machine, timezone and locale. The moment we changed the browser instead of the server, it showed up every time:
- Open Chrome DevTools, then More tools → Sensors.
- Set Location to Berlin (this overrides timezone and locale).
- Reload
/account/orderswith an order created between 00:00 and 01:00 Berlin time.
The production server runs in a container with TZ unset (so UTC) and no locale configured, so Node
falls back to en-US. Anyone whose browser locale or timezone differed from that got a mismatch.
What we ruled out
- Browser extensions injecting markup. A classic cause of hydration warnings, but the error reproduced in a clean guest profile.
- A stale CDN cache serving old HTML. The
x-nextjs-cacheheader wasMISSon the failing requests, and the build IDs in the HTML and the JS chunks matched. - Invalid nesting such as a
divinside ap. That produces a different message ("In HTML,<div>cannot be a descendant of<p>"), and the diff pointed straight at text content.
Root cause
OrderRow was a Client Component (it has an expand/collapse toggle) that formatted the order date
inline with new Date(order.createdAt).toLocaleDateString(). Client Components are still
pre-rendered on the server, so the same call ran twice: once in Node (UTC, en-US) and once in the
browser (user's timezone and locale). Whenever the two results differed, React refused to adopt the
server HTML and re-rendered the whole tree on the client, which caused the flash and, because a
parent Suspense boundary re-mounted, a second request to the orders API.
What is happening under the hood
Hydration is React attaching event handlers to HTML that the server already produced. For that to work, the first client render must produce exactly the same markup the server produced. React 19 treats a text mismatch as an error for the nearest Suspense boundary, throws away the server HTML for that subtree and renders it from scratch on the client.
Date.prototype.toLocaleDateString() with no arguments depends on two pieces of ambient state:
| Input | On the server (Node in our container) | In the browser |
|---|---|---|
| Timezone | TZ env var, otherwise the system zone, which was UTC |
The OS timezone of the user |
| Locale | ICU default locale, which was en-US |
navigator.language, for example de-DE |
An order created at 2025-01-13T23:30:00Z is the 13th in UTC but the 14th in Berlin (UTC+1) and
Singapore (UTC+8). Even within the same day, en-US prints 1/13/2025 while de-DE prints
13.1.2025. Either difference is enough to break hydration.
Environment
| Component | Version / value |
|---|---|
| OS (server) | Debian 12 container, node:22-bookworm-slim |
| Runtime | Node.js 22.12.0 (full ICU) |
| Framework | Next.js 15.1.3, App Router |
| React | 19.0.0 |
| Server timezone | TZ unset, effectively UTC |
| Affected users | Any browser whose timezone or locale differs from UTC / en-US |
Minimal reproduction
'use client';
export function OrderDate({ createdAt }: { createdAt: string }) {
// Runs on the server during pre-render AND in the browser during hydration.
return <time>{new Date(createdAt).toLocaleDateString()}</time>;
}
Render <OrderDate createdAt="2025-01-13T23:30:00Z" /> from a page, open it with the browser set to
Berlin, and the error appears on every load.
Who is affected
Any server-rendered React app (Next.js, Remix, Astro islands, plain renderToString) that formats
dates, numbers or currencies with locale or timezone defaults during render. Intl.NumberFormat
with no explicit locale has the same problem for prices: 1,234.50 versus 1.234,50.
There is no single correct fix. It depends on whether the date should be shown in the user's timezone, which is what our product team wanted for order history.
The fix
We moved formatting into a small component that renders a stable, timezone-independent value on the
server and switches to the user's local format only after hydration. useSyncExternalStore gives us
a clean "am I hydrated yet" signal without a useEffect flicker:
'use client';
import { useSyncExternalStore } from 'react';
const subscribe = () => () => {};
function useIsClient() {
// Server snapshot = false, client snapshot = true. Hydration uses the server snapshot,
// so the first client render matches the server HTML exactly.
return useSyncExternalStore(subscribe, () => true, () => false);
}
const isoDay = (iso: string) => iso.slice(0, 10); // 2025-01-13, identical on server and client
export function LocalDate({ iso, options }: { iso: string; options?: Intl.DateTimeFormatOptions }) {
const isClient = useIsClient();
const text = isClient
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', ...options }).format(new Date(iso))
: isoDay(iso);
return <time dateTime={iso}>{text}</time>;
}
Then the call site:
- <time>{new Date(order.createdAt).toLocaleDateString()}</time>
+ <LocalDate iso={order.createdAt} />
The server HTML contains 2025-01-13 inside a <time datetime="..."> element, which is also good for
crawlers and screen readers. Right after hydration React re-renders with the user's format, for
example 14.01.2025 for a German browser or 14 Jan 2025 for British English.
Alternatives and trade-offs
-
Format on the server with an explicit timezone and locale. If every user should see the same thing, such as an invoice in the merchant's timezone, pass both explicitly so the output is deterministic everywhere:
new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: 'Asia/Ho_Chi_Minh' }) .format(new Date(iso));Do this in a Server Component and no hydration is involved at all. You can store the user's preferred timezone in their profile or a cookie and pass it in.
-
suppressHydrationWarningon the<time>element. React then keeps the server text and does not patch it, so users would still see the UTC date. It silences the symptom without fixing the wrong date. We only use it for timestamps where a brief server value is acceptable. -
Client-only rendering with
next/dynamicandssr: false. This works but leaves an empty hole in the server HTML, causes layout shift and is heavier than the hook above for a single text node.
We also set TZ=UTC explicitly in the Dockerfile so the server's behaviour no longer depends on the
base image:
ENV TZ=UTC
How to test the fix
A unit test that renders the component on the "server" with one timezone and hydrates it with another catches regressions. With Vitest and React Testing Library:
import { renderToString } from 'react-dom/server';
import { hydrateRoot } from 'react-dom/client';
import { act } from 'react';
import { LocalDate } from './LocalDate';
test('hydrates without mismatch across timezones', async () => {
const errors: unknown[] = [];
const html = renderToString(<LocalDate iso="2025-01-13T23:30:00Z" />);
expect(html).toContain('2025-01-13');
const container = document.createElement('div');
container.innerHTML = html;
await act(async () => {
hydrateRoot(container, <LocalDate iso="2025-01-13T23:30:00Z" />, {
onRecoverableError: (e) => errors.push(e),
});
});
expect(errors).toEqual([]);
});
Run it with a different timezone than your machine to prove the point:
TZ=Europe/Berlin npx vitest run LocalDate
Confirming in production
After deploying we checked three things:
- In Sentry, the issue for "Hydration failed because the server rendered text didn't match" dropped from about 1,900 events per day to zero within an hour of the release, and stayed there.
- With DevTools Sensors set to Berlin, Tokyo and São Paulo,
/account/ordersloaded with no console errors and showed the local date after hydration. - The orders API request count per page view fell back from about 1.6 to 1.0, because the Suspense boundary no longer re-mounted and re-fetched.
Regression guard
- The hydration test above runs in CI with
TZ=Pacific/Kiritimati(UTC+14), which makes any server/client date drift show up immediately. - We added an ESLint
no-restricted-syntaxrule that flagstoLocaleDateString(),toLocaleString()andtoLocaleTimeString()calls with no arguments inside'use client'files, pointing developers toLocalDate.
// eslint.config.mjs (excerpt)
{
files: ['**/*.tsx'],
rules: {
'no-restricted-syntax': ['error', {
selector: "CallExpression[callee.property.name=/^toLocale(Date|Time)?String$/][arguments.length=0]",
message: 'Locale/timezone-dependent formatting during render breaks hydration. Use <LocalDate>.',
}],
},
}
Security implications
None beyond the bug itself. The fix does not expose new data: the ISO timestamp was already in the
page payload. If you store a user's timezone in a cookie to format on the server, validate it against
Intl.supportedValuesOf('timeZone') before passing it to Intl.DateTimeFormat, because an invalid
value throws a RangeError and can turn into a server error page.
Prevention checklist
- Never format dates, numbers or currencies with implicit locale or timezone during render in components that are server-rendered.
- Decide per screen whose timezone the user should see: theirs, the merchant's, or UTC.
- Pin
TZin container images so server behaviour is explicit. - Run at least one test or E2E job with a far-away timezone and a non-English locale.
- Treat hydration errors as real bugs, not console noise.
References
- React documentation:
hydrateRootand hydration mismatches — https://react.dev/reference/react-dom/client/hydrateRoot - Next.js documentation: Text content does not match server-rendered HTML — https://nextjs.org/docs/messages/react-hydration-error
- MDN:
Intl.DateTimeFormat— https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat - React documentation:
useSyncExternalStore— https://react.dev/reference/react/useSyncExternalStore
Add a label
Embed this post
<iframe src="https://hibug.me/nextjs-hydration-mismatch-date-locale" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
IDOR in a Django invoice download endpoint let users fetch other customers' PDFs
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.