Hydration Proof

Search documentation

Find a page or section

Fix a timezone hydration mismatch

The server runs in UTC. Your visitors do not.

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/418

hydration-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(), and toLocaleString() on a date
  • new Intl.DateTimeFormat() without a timeZone option
  • getHours(), getMinutes(), getDate(), getDay(), getMonth(), getFullYear() and getTimezoneOffset()
  • toDateString(), toTimeString() and toString() on a date
  • Intl.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:

app/dashboard/page.tsx
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} />;
}
app/dashboard/last-login.tsx
"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:

app/timezone-cookie.tsx
"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:

schedule.tsx
// 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:

local-time.tsx
"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-proof

Catch 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:

hydration-proof.config.ts
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