Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · January 14, 2025 · 9 min read · 47 Views
Banner: Next.js hydration mismatch caused by toLocaleDateString on server and browser

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:

  1. Open Chrome DevTools, then More tools → Sensors.
  2. Set Location to Berlin (this overrides timezone and locale).
  3. Reload /account/orders with 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-cache header was MISS on the failing requests, and the build IDs in the HTML and the JS chunks matched.
  • Invalid nesting such as a div inside a p. 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

  1. 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.

  2. suppressHydrationWarning on 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.

  3. Client-only rendering with next/dynamic and ssr: 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/orders loaded 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-syntax rule that flags toLocaleDateString(), toLocaleString() and toLocaleTimeString() calls with no arguments inside 'use client' files, pointing developers to LocalDate.
// 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 TZ in 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

Add a label

#nextjs #react #ssr #hydration #intl #timezone
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.