A timezone hydration mismatch happens when a component formats a date in the
runtime's time zone. The server usually runs in UTC and the browser in the
visitor's zone, so "5:00 AM" on the server becomes "10:00 AM" in Karachi. Pass
the same explicit timeZone to the formatter on both sides, or format the date
after mount.
Symptoms
React reports a text difference, and only for visitors whose zone gives a different result than the server's:
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: "Signed in at 5:00 AM" Client: "Signed in at 10:00 AM"
Minified React error #418; visit https://react.dev/errors/418hydration-proof reports it as HP1001 with the cause Timezone difference, and names both zones in the reason:
HP1001 Text differs between server and client (timezone difference, 95%)
#last-login in LastLogin
server: "Signed in at 5:00 AM"
client: "Signed in at 10:00 AM"
app/dashboard/LastLogin.tsx:14:10
→ Pass an explicit timeZone to the formatter (the same on both sides), or format the date after mount.Times differ for almost every visitor. Dates differ only near midnight, which makes the bug look random: a "Posted on May 3" line breaks for some visitors in the evening and for nobody in the morning.
Why a timezone hydration mismatch happens
A Date holds one moment. Turning it into text or into parts (hours, day,
month) needs a time zone, and every JavaScript formatter uses the runtime's
zone unless you pass one.
Mixing local dates in the browser timezone with UTC dates on the server is the classic case.
These read the runtime's zone:
toLocaleDateString(),toLocaleTimeString(), andtoLocaleString()on a datenew Intl.DateTimeFormat()without atimeZoneoptiongetHours(),getMinutes(),getDate(),getDay(),getMonth(),getFullYear()andgetTimezoneOffset()toDateString(),toTimeString()andtoString()on a dateIntl.DateTimeFormat().resolvedOptions().timeZone, which returns the zone itself
The date does not need to be "now". new Date(post.publishedAt) is the same
moment on both sides, and still prints a different hour in Karachi than in UTC.
When the value really is the current time, fix that first: see
date and time hydration errors.
How to fix it
Use one time zone on both sides
Decide the zone on the server and pass it down. The visitor's own zone works if the server can know it, for example from a profile setting or a cookie the browser set on an earlier visit:
import { cookies } from "next/headers";
import { LastLogin } from "./last-login";
export default async function Page() {
const timeZone = (await cookies()).get("tz")?.value ?? "UTC";
const user = await getUser();
return <LastLogin at={user.lastLoginAt} timeZone={timeZone} />;
}"use client";
export function LastLogin({ at, timeZone }: { at: number; timeZone: string }) {
const time = new Date(at).toLocaleTimeString("en-US", {
timeZone,
hour: "numeric",
minute: "2-digit",
});
return <p id="last-login">Signed in at {time}</p>;
}Set the cookie from the browser once, after hydration, so the next request renders in the right zone:
"use client";
import { useEffect } from "react";
export function TimeZoneCookie() {
useEffect(() => {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
document.cookie = `tz=${zone}; path=/; max-age=31536000; samesite=lax`;
}, []);
return null;
}The first visit renders in UTC. Every visit after it matches the visitor.
Intl.DateTimeFormat timeZone: hydration-safe formatting
When one zone is right for everyone (an event schedule, a log viewer, a changelog), pin it in the formatter. Replace local getters with their UTC versions:
// Before: the runtime's zone, different on each side
const localFormat = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
});
// After: the same zone everywhere
const format = new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
dateStyle: "medium",
timeStyle: "short",
});
export function Posted({ at }: { at: number }) {
const date = new Date(at);
// getUTCDate() instead of getDate(), getUTCMonth() instead of getMonth()
return (
<p>
{format.format(date)} ({date.getUTCDate()}/{date.getUTCMonth() + 1})
</p>
);
}A locale-sensitive formatter also needs an explicit locale, or the two sides still disagree about the format. See locale formatting.
Format in the visitor's zone after mount
If the server cannot know the zone and the time must be local, render a neutral value first and replace it in an effect. Effects never run on the server, so the first client render still matches:
"use client";
import { useEffect, useState } from "react";
export function LocalTime({ at }: { at: number }) {
const [text, setText] = useState(() =>
new Date(at).toISOString().slice(0, 10)
);
useEffect(() => {
setText(
new Date(at).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
);
}, [at]);
return <time dateTime={new Date(at).toISOString()}>{text}</time>;
}The two-pass rendering pattern explains the trade-off: the placeholder shows until the page hydrates.
i18n libraries
next-intl and similar libraries format with a configured zone. Set timeZone
in the request config so the server and the client provider use the same one.
The locale guide
shows the setup.
Catch it with ESLint
no-timezone-without-explicit-timezone
reports every formatter and getter above in render code. It suggests adding
timeZone: 'UTC' (or the zone in its defaultTimeZone option) and swapping
getHours() for getUTCHours(). Server Components are skipped, because they
only render on the server.
npm install -D eslint-plugin-hydration-proofCatch it in CI
The default scenario uses the machine's own timezone, and a server started on the same machine renders with it, so the two sides agree. Test other zones with the environment matrix:
import { defineConfig } from "hydration-proof";
export default defineConfig({
matrix: {
timezoneId: ["UTC", "Asia/Karachi", "America/Los_Angeles"],
},
});Each finding is reported as HP1001, and the report names the values that
separate failing pages from passing ones ("Only found with timezone
Asia/Karachi"). With --probe, hydration-proof reloads the page with only the
browser's timezone switched to the server's. If the finding disappears, the
timezone is the proven cause. See the environment matrix
and probes.
npx hydration-proof test --probe