Dates and time zones in the UI
If you've shipped a booking system, calendar, or any time-sensitive feature across regions, you've probably hit the moment where a user in Tokyo sees a different time than what you stored. Dates and time zones in the UI are deceptively complex—not because of the math, but because of where decisions get made and when they get validated. I'm going to walk you through the mental model that stops these bugs before they reach production.
Store and transfer timestamps in UTC ISO format
The golden rule: store everything in UTC, transfer in ISO 8601 format. This sounds like dogma, but it's actually just the path of least resistance. When your backend processes a booking at 14:30 Singapore time, it doesn't know or care about Singapore. It knows UTC. Your database knows UTC. Your APIs speak UTC.
Here's what this looks like in practice. A user books a flight from their browser in New York. Their local time is 2:00 PM EST. You capture that moment, convert to UTC (which would be 19:00 UTC that same day), and send it to your API:
// Client captures local time
const userLocalTime = new Date(); // 2024-01-15T14:00:00 (user's browser time)
const utcTimestamp = userLocalTime.toISOString(); // "2024-01-15T19:00:00Z"
// Send to API
const response = await fetch('/api/bookings', {
method: 'POST',
body: JSON.stringify({
departureTime: utcTimestamp,
passengerId: 123
})
});
On the backend, you store that ISO string directly—no conversion needed. PostgreSQL's TIMESTAMP WITH TIME ZONE type handles this beautifully:
// Pseudocode: your backend (NestJS example)
@Post('bookings')
createBooking(@Body() body: CreateBookingDto) {
return this.bookingService.create({
departureTime: new Date(body.departureTime), // "2024-01-15T19:00:00Z"
passengerId: body.passengerId
});
}
// Database query
INSERT INTO bookings (departure_time, passenger_id)
VALUES ('2024-01-15T19:00:00+00:00', 123);
When you retrieve that booking on the client later, you get the same ISO string back. No ambiguity. No "is this UTC or local?" questions. The Z at the end of the ISO format explicitly signals UTC.
Format with Intl.DateTimeFormat
Now the user wants to see that 19:00 UTC timestamp in their local time. This is where Intl.DateTimeFormat becomes your best friend. It respects the user's system time zone, handles localization, and avoids the fragility of libraries like moment.js.
In a React component, you'd do something like this:
interface BookingDisplayProps {
departureTime: string; // ISO format, e.g., "2024-01-15T19:00:00Z"
}
export function BookingDisplay({ departureTime }: BookingDisplayProps) {
const date = new Date(departureTime);
// User's browser automatically determines their time zone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York', // Or read from user settings
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
});
return (
<div>
<p>Departure: {formatter.format(date)}</p>
</div>
);
}
If your app has a user preference for time zone (common in SaaS dashboards), you can pass it explicitly. Otherwise, just use the browser's default:
// Simpler: let the browser decide
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
console.log(formatter.format(new Date("2024-01-15T19:00:00Z")));
// Output depends on user's system time zone:
// → "Jan 15, 2024, 02:00 PM" (EST in New York)
// → "Jan 16, 2024, 12:30 AM" (IST in India)
The beauty of Intl.DateTimeFormat is that it's built into the browser, requires no dependencies, and respects the user's locale and time zone settings. You're not doing string manipulation or timezone math yourself—the browser does the heavy lifting.
Be explicit about the time zone in bookings
Here's where many teams slip up: they show a formatted time but never tell the user which time zone it's in. In a ride-hailing or travel app, that ambiguity is dangerous.
If I book a car for "3:00 PM" and the system shows me "3:00 PM EST" but I'm actually in PST, I've just missed my ride by three hours. The fix is simple: always display the time zone abbreviation or offset next to the time.
interface BookingCardProps {
departureTime: string;
userTimeZone?: string; // e.g., "America/New_York"
}
export function BookingCard({ departureTime, userTimeZone }: BookingCardProps) {
const date = new Date(departureTime);
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: userTimeZone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short' // Adds "EST", "PST", etc.
});
const parts = formatter.formatToParts(date);
const tzName = parts.find(p => p.type === 'timeZoneName')?.value || '';
return (
<div>
<h3>Your Booking</h3>
<p>
Departure: <strong>{formatter.format(date)} {tzName}</strong>
</p>
<p>
Server time (UTC): {date.toISOString()}
</p>
</div>
);
}
For travel and logistics, I often go one step further and show the UTC time in small print for reference. Users in multi-timezone teams appreciate knowing the "absolute" time—especially if they're coordinating across regions.
Watch hydration mismatches from server vs client time
This is the gotcha that bites every Next.js developer at least once. If you render a formatted date on the server and a different formatted date on the client, React will complain about a hydration mismatch and your app degrades to single-threaded mode.
Here's the problem: the server doesn't know the user's time zone, so it formats in UTC. The client knows the user's time zone, so it formats differently. Mismatch.
// ❌ WRONG: Server and client render different output
export default function BookingPage({ booking }) {
// Server: renders "2024-01-15T19:00:00Z" (UTC)
// Client: renders "Jan 15, 2024, 2:00 PM EST" (local)
// Mismatch!
const date = new Date(booking.departureTime);
return <p>Departs: {date.toLocaleString()}</p>;
}
The fix is to suppress rendering on the server and only render on the client:
// ✅ CORRECT: Only render on client
'use client';
import { useEffect, useState } from 'react';
export function BookingDisplay({ departureTime }) {
const [formattedTime, setFormattedTime] = useState('');
useEffect(() => {
const date = new Date(departureTime);
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
setFormattedTime(formatter.format(date));
}, [departureTime]);
// Empty on first render, populated on client
return <p>Departs: {formattedTime}</p>;
}
Alternatively, if you're using Next.js 13+ with the App Router, you can mark the component as a client component with 'use client' at the top and safely use useEffect to format after hydration. The key is: never format dates differently on server vs. client in the same render pass.
Another pattern is to pass the pre-formatted time as a prop from the server if you absolutely need SSR, but that usually means calculating the user's time zone on the server first—which requires reading it from cookies or headers. Most teams find it simpler to defer formatting to the client.
Common mistakes
- Storing dates in local time: You capture
new Date()on the client without converting to UTC. The same timestamp means different things in different time zones. Always convert to UTC before sending to the backend. - Mixing time zone libraries: Using moment.js, date-fns, and dayjs inconsistently in the same app. Stick to one library or use
Intlfor most cases. Fewer dependencies, fewer bugs. - Forgetting to display the time zone: Showing "3:00 PM" without "EST" is ambiguous. Always include the zone abbreviation or offset next to formatted times, especially for bookings.
- Hydration mismatches in Next.js: Rendering formatted dates during SSR but the client renders a different format because it uses the user's time zone. Defer all formatting to the client with
useEffector mark components as client-side. - Not testing across time zones: Testing only in your local time zone and shipping. Use your browser's DevTools to simulate different time zones, or test with users in other regions before launch.
Key takeaways
- Store all timestamps in UTC, transfer in ISO 8601 format, and retrieve the same format. Never store local time.
- Format for display with
Intl.DateTimeFormat. It handles time zones, locales, and browser quirks without external dependencies. - Always show the time zone abbreviation or offset next to formatted times in user-facing bookings and confirmations.
- In Next.js, defer date formatting to the client with
useEffector'use client'components to avoid hydration mismatches. - Test your date and time zone logic across regions before shipping. Timezone bugs often surface only in production.






Comments
Loading comments…