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 the server and browser format the same date in different zones. A server in UTC might show "5:00 AM" while a visitor in Karachi sees "10:00 AM". Pass one explicit timeZone to both renders, or format in the visitor's zone after hydration.

Symptoms

React reports a text mismatch when the two zones produce different output:

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 changed text as HP1001 and shows both values:

HP1001 Text differs between server and client  ·  app/dashboard/LastLogin.tsx:14
  server "Signed in at 5:00 AM"  → client "Signed in at 10:00 AM"
  Possible cause: Timezone difference.  Fix: Set the same timeZone on both sides.

Some dates differ only near midnight, so the bug may affect only certain visitors or times of day.

Why a timezone hydration mismatch happens

A Date represents one moment. Formatting that moment uses a timezone. Without an explicit zone, each runtime uses its own default. Mixing local dates in the browser timezone with UTC dates on the server is a common 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

This applies to stored dates too, not just the current time. If the moment itself changes between renders, start with the time guide.

How to fix it

Use one time zone on both sides

If the server knows the user's timezone from a profile or cookie, pass it to the component:

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>;
}

You can save the browser's timezone after hydration for later requests:

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 uses UTC; later requests can use the saved zone.

Intl.DateTimeFormat timeZone: hydration-safe formatting

If one zone is right for everyone, set it in the formatter. Use UTC getters when reading date parts yourself:

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>
  );
}

Set the locale too, or the same time may still be formatted differently. See locale formatting.

Format in the visitor's zone after mount

If the server cannot know the zone, show a stable value first and format the local time after hydration:

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 guide explains the temporary first value.

i18n libraries

For next-intl, set timeZone in the request config so both sides receive the same value. See the locale guide.

Catch it with ESLint

no-timezone-without-explicit-timezone flags local formatters and getters in render code. It suggests an explicit zone or UTC getter where appropriate.

npm install -D eslint-plugin-hydration-proof

Catch it in CI

A local run may use the same timezone on both sides. Add other zones to the environment matrix:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  matrix: {
    timezoneId: ["UTC", "Asia/Karachi", "America/Los_Angeles"],
  },
});

The report shows which timezone produced the finding. Use --probe to change only the browser timezone and test the cause. See the environment matrix and probes.

npx hydration-proof test --probe