The Date That Shifted by Itself: Pin the Timezone at Render
Data right, display wrong: toLocaleString without timeZone lets a UTC server and a WIB browser render different dates. Pin it at the presentation layer.
TL;DR
A date bug blamed on stored data was actually the render layer: toLocaleString was called with the id-ID locale but no timeZone option, so servers in UTC and browsers in WIB disagreed. Timestamps between 17:00 and 24:00 UTC crossed midnight once converted. Fix: store UTC, render with an explicit timeZone, locked by a test.
01:00 WIB, and the Date Was Tomorrow
A citizen opened the service status page at 01:00 WIB and read the submission date as the following day. The admin panel, for the same ticket, showed today. No data moved; what differed was only who rendered it.
My first suspicion pointed at the server clock or the date column in the database. Inspection showed every timestamp stored correctly in UTC. What was wrong was not storage but the render layer: several call sites invoked toLocaleString with the id-ID locale but without the timeZone option. The MDN documentation states the output follows the timezone of the machine running the code[4], and that the locale only controls language and formatting, not the timezone[4].
Why the Difference Only Surfaced Now
As long as browser and server agreed on the clock, the bug stayed invisible. It surfaced once the server ran in a UTC container while browsers sat at UTC+7: the string rendered by the server differed from the client's hydration output. In frameworks like Next.js, pages are Server Components by default, rendered on the server and streamed to the client[5], so hydration must reproduce the exact same output. Timestamps between 17:00 and 24:00 UTC fall on the next calendar day when computed in WIB, and that is where users started seeing dates shift on their own.
Fix the Presentation, Not the Data
The architectural decision was explicit: data stays UTC in storage and the API, and the presentation layer pins WIB. One formatDateTime utility replaced the naked toLocaleString calls:
export function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Asia/Jakarta",
});
}
The utility is used on the status page, the ticket flow, and the admin history; short timestamps like lastSync got the same timeZone option inline.
Anyone can verify this: render a 17:00 UTC timestamp with and without the timeZone option on a UTC+7 machine, then compare the date part. Without the option, the date jumps to tomorrow. With it, the output is consistent. A Vitest test locks the +07:00 shift across the UTC midnight boundary so the regression cannot sneak back in.
Store UTC, Render with an Explicit Zone
The portable lesson is short and general: store in UTC, render with a timezone written down explicitly. The locale handles language, timeZone handles consistency. They are different parameters, and only one of them may be delegated to the calling machine.
After the change, the status page and the admin panel read the same date for the same ticket, whenever and from whatever timezone the page is opened. A difference that looked like a data bug was, in the end, one option that had not been written.
Sources
- Date.prototype.toLocaleString() - JavaScript | MDN (accessed 2026-10-12).
- Server Components - Next.js (accessed 2026-10-12).