React hydration error fixes: dates, locales and modals
Two React hydration error causes from a Next.js stock photo site: dates formatted in the visitor's locale, and a modal that opened too early. The fix for each.
By Hamza Ghouri
A React hydration error means the HTML the server sent does not match what React renders in the browser on the first pass. React then discards the server HTML for that part of the tree and renders it again on the client, which costs you the speed you rendered on the server for. This post covers two causes I fixed on Fotobank, a Next.js stock photo site: a date formatted in the visitor's locale, and a welcome modal that opened before the page had finished hydrating. Both are common, and both have small fixes.
What a hydration mismatch costs you
When a page is server-rendered, the browser gets finished HTML and shows it straight away. React then hydrates it: it renders the same components in the browser and attaches event handlers to the existing DOM. For that to work, the first client render has to produce the same output the server did.
When it doesn't, React logs a mismatch, throws away the server HTML up to the nearest Suspense boundary (or the whole root), and renders that part from scratch on the client. The page still works. It just does the work twice, may flash or shift, and fills the console with warnings that make real problems harder to spot. The Next.js hydration error page lists the usual causes: browser-only APIs during render, time and locale differences, invalid HTML nesting and browser extensions.
The first two causes were the ones I hit.
Dates formatted in the visitor's locale
Fotobank's image page shows when a photo was uploaded. The component formatted that date with toLocaleDateString() and no arguments, which means "use whatever locale this machine has".
On the server, that was a US English locale, so the HTML said 10/28/2026. A visitor in the UK has a British locale, so their browser rendered 28/10/2026 for the same date. Same component, same data, different text. React saw the mismatch, threw the server HTML away and re-rendered.
Time zones cause the same problem in a quieter way. A server running in UTC and a visitor eight hours ahead can disagree about which day a timestamp falls on, and you only see it for uploads near midnight.
The fix is to stop letting the machine decide. Pick a fixed locale and a fixed time zone, so the server and every browser produce identical text:
const uploaded = new Date(image.createdAt).toLocaleDateString("en-US", {
timeZone: "UTC",
});
That is the whole change on the image page: a fixed locale and UTC. Both arguments matter. A fixed locale alone still breaks across time zones, and UTC alone still breaks across locales. The same options work with Intl.DateTimeFormat if you want to create the formatter once and reuse it.
When you do want the visitor's own date format
Sometimes a fixed format is the wrong product choice, for example a booking time that must read in the visitor's own zone. In that case the server can't know the right output, so don't render it on the server. My default is to render a neutral value on the server (the fixed-format date, or nothing), then swap in the local version in an effect after hydration. The first client render matches the server, and the local text arrives a moment later. Josh Comeau's The Perils of Hydration walks through this two-pass rendering approach in detail.
React also has suppressHydrationWarning for a single element whose text is expected to differ, such as a timestamp. I keep it for that narrow case. Using it to silence a mismatch you don't understand only hides the bug.
A modal that opened before the navbar hydrated
The second mismatch was harder to read, because the error pointed at the navbar and nothing in the navbar had changed.
Fotobank shows a welcome modal to first-time visitors. It used the site's dialog component, which is built on Radix Dialog, and it opened as soon as it mounted. A modal dialog in Radix marks everything outside it with aria-hidden so screen readers stay inside the dialog. That is correct behaviour for accessibility.
The trouble was timing. The navbar sits inside a Suspense boundary, and the modal opened before that boundary had hydrated. The dialog set aria-hidden on the navbar's DOM, then React hydrated the navbar, compared it with what it expected, found an attribute the server never sent, and reported a mismatch.
The fix was to open the modal after a short delay, so everything on the page has hydrated before the dialog touches the DOM around it:
useEffect(() => {
const t = setTimeout(() => setOpen(true), 800);
return () => clearTimeout(t);
}, []);
The cleanup matters: if the component unmounts before the timer fires, nothing tries to open a dialog that is already gone. A welcome modal is not urgent, so a short wait costs nothing.
The general lesson: anything that writes to DOM it does not own (dialogs, focus traps, scroll locks, third-party widgets) should wait until hydration is done. A mismatch error that points at an innocent component often means something else changed that component's DOM first.
How to track down a React hydration error
My process, in order:
- Read the diff React prints. Recent versions show the server value and the client value. A date, a number format or a random ID usually jumps out.
- Reproduce with a different browser locale or time zone. Most browsers' developer tools can override both.
- If the error points at a component that looks stable, check what else touches its DOM during load: modals, extensions, analytics scripts.
- Disable browser extensions once. They inject attributes too, and you don't want to chase a bug only your own browser has.
A related fix: load the main image with priority
While on these pages I also changed the news lead image to load with priority on next/image, so the browser fetches the largest image above the fold early. It is unrelated to hydration but sits in the same code path, and it is the kind of small fix worth making while the file is open. On Next.js 16, priority is deprecated in favour of preload, so use whichever your version supports.
Fotobank had other page-level fixes from the same round, including share images that went missing from Next.js metadata and a fixed header that hid page titles. For checking that a change like this did not alter output elsewhere, I use the approach in verifying a refactor by diffing old and new output.
Why does toLocaleDateString cause a hydration error?
Called without arguments, toLocaleDateString() formats with the locale and time zone of the machine running it. The server and the visitor's browser often differ, so they produce different text for the same date, and React reports a hydration mismatch. Pass a fixed locale and timeZone: "UTC" and both sides produce the same string.
The short version
Format dates with a fixed locale and time zone, and render anything that must be local after hydration. Delay modals and anything else that writes to DOM it doesn't own until the page has hydrated. Read React's diff before guessing. If you'd like a second pair of eyes on a Next.js codebase, that is part of the work I do.