A server component cannot know what time it is where you are. It knows the instant, it does not know your offset, and nothing in a request tells it: there is no time zone header, and Intl.DateTimeFormat() on the server resolves to the server's zone, which in our case is whichever region the function cold started in.
This is a problem Nakodo runs into on nearly every screen, because the app is mostly about things that will happen later. A search that resumes when a daily limit resets. An email that goes out on a weekday between 9am and 7:30pm where the recipient is. A first email sitting in its approval hour. Those are all instants, and an instant is useless to a reader until it is rendered in their own clock.
The usual answers are all worse than they look. Rendering on the server in UTC is correct and unreadable. Guessing from the account's country means a wrong guess for anyone travelling. Rendering nothing until an effect runs gives you a layout that jumps. And formatting in the browser without care gives you the oldest React error there is: server HTML says one thing, the first client render says another, hydration complains, and in production you get whichever one won.
The whole mechanism is one hook with a null server snapshot
src/components/local-time.tsx is 78 lines and exports three components. This is the first:
const noSubscribe = () => () => {};
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString();
// A time in the reader's own timezone. The server doesn't know it, so the
// server render shows `fallback` and the browser fills in the time.
export function LocalTime({ iso, fallback }: { iso: string; fallback: string }) {
const text = useSyncExternalStore(
noSubscribe,
() => {
const d = new Date(iso);
return new Intl.DateTimeFormat(undefined, {
weekday: sameDay(d, new Date()) ? undefined : "short",
hour: "numeric",
minute: "2-digit",
}).format(d);
},
() => null,
);
return <time dateTime={iso}>{text ?? fallback}</time>;
}
useSyncExternalStore is documented as the hook for subscribing to something outside React, and that framing hides its second job. The three arguments are subscribe, getSnapshot, and getServerSnapshot, and the last one is the interesting part: React calls it during server rendering and during hydration, then calls getSnapshot for every render after that. So a store that never changes, with a getServerSnapshot that returns null, is a supported way to render one thing on the server and another in the browser without a hydration warning.
noSubscribe returns an empty unsubscribe function. There is nothing to subscribe to. The reader's time zone does not change while they look at the page, so re-reading it would be pointless work.
undefined as the first argument to Intl.DateTimeFormat is the whole localisation strategy. It means "the runtime's locale", which in the browser is the reader's. We pass no locale anywhere in this file.
The fallback is a sentence, not a placeholder
This is the part I would argue about with my past self. The obvious thing to pass as fallback is a dash, a skeleton, or the UTC time. We pass a sentence that is true in a different unit:
Searches resume at{" "}
<LocalTime
iso={held.at.toISOString()}
fallback={held.reason === "later" ? "a later time" : held.reason === "social" ? "midnight UTC" : "midnight Pacific time"}
/>
The server renders "Searches resume at midnight Pacific time". The browser replaces that with "Searches resume at 08:00", or "Searches resume at Fri 08:00" if it is not today. Both sentences are correct. One is specific to a reader with a clock, the other is specific to the limit being described, which happens to reset on YouTube's quota day in Pacific time.
So the HTML in the response is never a stand-in for missing information. It is the version of the fact that does not need a browser. If JavaScript never runs, the sentence still reads. For a reader whose daily YouTube search allowance has run out, "midnight Pacific time" is arguably more informative than their own 08:00, because it tells them whose midnight is in charge.
<time dateTime={iso}> wraps both, so the machine readable instant is in the markup either way.
Two clocks in one phrase
The second component has a harder job. When the app tells a brand that an email is going out, there are two times worth knowing: the reader's, because that is when they can still stop it, and the recipient's, because the recipient's working hours are usually the reason it is not going out sooner.
export function SendTime({ iso, there, fallback }: { iso: string; there: string; fallback: string }) {
const text = useSyncExternalStore(noSubscribe, () => sendTime(iso, there), () => null);
return <time dateTime={iso}>{text ?? fallback}</time>;
}
sendTime renders "tomorrow at 19:25 (13:25 in New York)", and drops the bracket when the two agree:
const theirDay = calendarDay(d, there);
const theirTime = clock(there).format(d);
if (theirDay === day && theirTime === time) return when;
Two details in there are worth stealing.
Comparing days means comparing formatted strings, not timestamps:
const calendarDay = (d: Date, timeZone?: string) =>
new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(d);
en-CA is there because it formats as 2026-10-09, which sorts, parses and compares. Asking whether two instants fall on the same calendar day in two different zones is otherwise a pile of offset arithmetic; formatting both in the zone you care about and comparing strings is three lines and has no edge cases. Dividing the difference of two parsed days by 86,400,000 then gives you "today", "tomorrow" or a weekday.
And the place name is derived, not stored:
const place = there === "UTC" ? "UTC" : `in ${there.split("/").at(-1)!.replaceAll("_", " ")}`;
America/New_York becomes "in New York" and Europe/London becomes "in London". It is a trick, and the trick has known limits: America/Sao_Paulo renders as "Sao Paulo" without its accent, because an IANA zone name is ASCII by rule. We accepted that, because a lookup table of a few hundred zone names is a table somebody has to maintain, and the string is a parenthetical, not an address.
Twenty countdowns, one refresh
The third component is the one that does something other than format. A queued email has an hour before it is approved; the UI shows the time left, and when it runs out the page needs to show the new state.
const everyFewSeconds = (tick: () => void) => {
const t = setInterval(tick, 10_000);
return () => clearInterval(t);
};
let refreshedAt = 0;
export function Countdown({ iso, fallback }: { iso: string; fallback: string }) {
const router = useRouter();
const text = useSyncExternalStore(everyFewSeconds, () => timeUntil(iso), () => null);
const due = text === "a moment";
useEffect(() => {
if (!due || Date.now() - refreshedAt < 5_000) return;
refreshedAt = Date.now();
router.refresh();
}, [due, router]);
return <time dateTime={iso}>{text ?? fallback}</time>;
}
Here the store does change, so subscribe is a real interval, and getSnapshot returns a formatted string from a pure function. React bails out of re-rendering when the snapshot is identical, so a ten second tick that still says "42 min" costs nothing: the string is the same string, and nothing renders. The component only re-renders on the ticks where the text changes.
refreshedAt is a module level variable, deliberately. Approvals are created in batches, so a queue can easily hold a dozen emails whose hour ends within the same minute, and every one of those components will independently decide it is due. A ref would be per component and would not help. One module scoped timestamp, shared by every mounted Countdown, turns a dozen router.refresh() calls into one.
Comparing text === "a moment" rather than recomputing the deadline looks lazy and is actually the point: timeUntil already owns the rounding, so the countdown reads "a moment" and the refresh fires on exactly the same condition the reader sees. Two sources of truth for "has this expired" is how you get a page that says "a moment" forever.
What this is not
It is not a time zone picker, and it is not Intl.DateTimeFormat().resolvedOptions().timeZone written into a cookie. Nothing here stores the reader's zone, sends it to the server, or persists it. Three components, one hook, one null, and the server keeps rendering sentences that are true without a clock.
Top comments (0)