Skip to content
Chapter 83Lesson 3

Timezone on the profile

Storing each user's IANA timezone as a profile column read from the session and passed into every formatter and scheduler.

Three things your web app will need all break on the same question. The monthly billing email has to arrive at 9 AM in the customer’s morning, the “due in 3 days” reminder has to count three days from where the user is standing, and the activity feed has to show each timestamp the way that user reads a clock on their own wall. Every one of these needs a timezone, and the question that decides whether they work is: whose?

The previous lessons set the storage shape for instants and calendar days, and named a zone explicitly whenever wall-clock time entered the picture, but left this question open: which zone.

Here is the answer this lesson defends: the user’s timezone is a column on their profile. It is data that follows the user, read from the session and passed by hand into every formatter and scheduler you write. Not derived per request, not guessed from the request. Stored.

The tempting shortcut is to derive the zone at runtime from the server or the request. It looks correct on your laptop, ships without an error, and formats every user’s timestamps in the wrong timezone in production. The profile column makes that mistake impossible to write.

Make this decision before you add a column or write a line of code. Choosing wrong doesn’t cost you a compile error; it costs you a bug that surfaces months later, in production, on a customer’s screen, with no stack trace pointing at the cause. There are three plausible places to get a user’s timezone, and two of them are traps, tempting precisely because they’re less work.

Walk the decision below and click into each shortcut to see where it fails. The answer is “store it,” so the goal is to feel why the other two collapse, which is how you’ll recognize them when an AI suggests one or a teammate reaches for one in review.

Where does the user's timezone come from?

The rule to carry out of that walk: a user’s timezone is data you store about them, never a value you derive from the runtime and never a guess you pull from the request. One line follows, worth memorizing because it names the exact mistake: never call the no-argument Intl.DateTimeFormat() in server code. It will lie to you, and it will lie quietly.

The lie is quiet because of where it lives. On your laptop, Intl.DateTimeFormat().resolvedOptions().timeZone returns your real zone, whatever your OS is set to, so the timestamps look perfect and you ship. On Vercel the runtime’s TZ is nailed to UTC, and the same call returns 'UTC' for everyone, with no exception and no log line. Your German users just start seeing every timestamp two hours off and their reminders firing at 2 AM, and the only signal is a confused support ticket. “Passes locally, wrong in prod, no error” is the most expensive bug shape there is, and deriving the timezone per request is built entirely out of it.

The users.timeZone column: an IANA name, never an offset

Section titled “The users.timeZone column: an IANA name, never an offset”

The timezone lives on the profile, as a single text column on the users table. The one modeling decision inside it is where beginners reliably reach for the wrong thing.

The column stores an IANA timezone name: 'America/New_York', 'Europe/Berlin', 'Asia/Tokyo', 'UTC'. It’s NOT NULL with a default of 'UTC', the safe fallback for rows that predate the column and for when browser detection returns nothing.

The trap is to store an offset instead: to write -05:00 and think you’ve stored New York’s timezone. You haven’t. An offset is not a timezone. It’s a timezone’s value at one single instant. New York is -05:00 in January and -04:00 in July, because daylight saving moves the clock, so -05:00 alone tells you nothing without knowing the month. Store the offset and you’ve thrown away the daylight-saving rules: every spring, when the clocks jump forward, all of that user’s times silently shift by an hour though no code changed.

Store the name, America/New_York, and the platform resolves the right offset for any instant from the daylight-saving rules. An IANA name is a rule that spans the year; an offset is one frozen sample of it, correct only until the next transition.

America/New_York
IANA name a rule across the year
−05:00 EST
−04:00 EDT
−05:00 EST
spring forward
fall back
Fixed offset one frozen sample
−05:00
wrong half the year
JanFebMarAprMayJunJulAugSepOctNovDec
An IANA name is a rule across the whole year; an offset is one frozen sample of it.

Here’s the column, with the snake-case casing set on the Drizzle client (so timeZone in TypeScript maps to time_zone in SQL):

db/schema.ts
// on the users table
timeZone: text('time_zone').notNull().default('UTC'),
locale: text('locale').notNull().default('en-US'),

The second line, locale, travels with timeZone everywhere, so you’ll always see both on the profile; showing only one would misrepresent the table. But it belongs to the next chapter. Locale drives number formatting, weekday names, and currency; timezone drives dates and times. The two are independent: a Berlin user can read your app in en-GB. This chapter owns the timezone half and leaves locale alone.

One more term makes “store the name” safe. The tzdata that ships with Node and the browser is what turns America/New_York into the right offset for a given date. Because it’s updated several times a year as countries change their rules, you store the name and let the platform resolve it.

A column hard-coded to 'UTC' is useless. A real zone has to get into it, and exactly one place in the system knows that zone: the browser. The user’s operating system holds their setting, and the browser exposes it through Intl.DateTimeFormat().resolvedOptions().timeZone, the same call that lied to you on the server. Only where it runs differs. In a Vercel function it reports the runtime’s zone, UTC; in the browser it reports the user’s own machine. This is the one place the no-argument form is correct, because here the runtime is the user’s computer.

There are three sensible ways to capture it, trading friction against certainty:

  • Detect it at the sign-up form. Read the browser’s zone, drop it into the sign-up payload, and let it land in the column at account creation. Zero friction, and right the vast majority of the time. This is the default.
  • An onboarding picker. A post-sign-up step where the user chooses their zone from a list. More friction, fully explicit. Reach for it when getting the zone exactly right matters more than a frictionless sign-up.
  • A 'UTC' fallback plus a first-sign-in prompt. The cheapest path for accounts that already exist without a zone: default them to 'UTC', then nudge them to confirm next time they sign in.

In practice: detect from the browser, fall back to 'UTC' if detection comes back empty, and let the user fix it on their profile page later. Keep one framing in mind about that detected value: it’s user-asserted, not authoritative. A sensible default the user owns and can correct, not ground truth you’d bet the billing run on.

This is Storage, domain, edge’s “never trust the client clock,” pointed at a different fact. The client reports; the server decides what to keep. A client-reported timezone is fine to accept where a client-reported createdAt is not, because the user owns their own timezone and can edit it whenever it’s wrong. It’s their assertion about themselves, not a claim about the system’s state.

app/(auth)/sign-up/timezone-field.tsx
'use client';
// runs in the browser — so this is genuinely the USER's zone, not the server's
const detectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// ...inside the sign-up form
<input type="hidden" name="timeZone" defaultValue={detectedTimeZone} />;

The 'use client' directive does real work here: it guarantees the code runs in the browser, the only context where Intl.DateTimeFormat() tells the truth. The detected zone rides into the sign-up Server Action as a hidden field, named timeZone to match the action’s schema key.

A hidden input is still user-supplied, so it’s untrusted: anyone with devtools can edit it. Before this value goes near the column, the server validates it. That’s the next section.

Once the column is populated, code needs to get the zone back out. There are two seams for that, both surfaces you already own.

The first is the session. You already call requireOrgUser() at the top of your actions, pages, and route handlers; it returns { user, orgId, role }, and user now carries timeZone. In any authenticated context, the zone is one destructure away. Reach for this first.

The second is a small helper, getCurrentUserTimeZone(), in lib/user-time.ts. Picture a Server Component buried six levels deep that needs the zone to format one timestamp. Threading timeZone down as a prop through every intervening component is the kind of plumbing that rots; a thin React-cached read fetches it directly instead. It resolves through the same session read, calling requireOrgUser() (or getCurrentUser()) under the hood, so it’s not a second source of truth, just a more convenient door to the one source.

Whichever seam you reach through, the rule is the same: pass the zone explicitly into every Temporal and Intl call. No module-level “current timezone” variable, no no-argument call that lets the runtime decide. That ambient, global form is the exact door the Vercel-UTC bug walks through, and you keep it shut by never opening it.

const { user } = await requireOrgUser();
// "what calendar day is this instant, for THIS user?" — zone named, never ambient
const localDay = invoice.createdAt.toZonedDateTimeISO(user.timeZone).toPlainDate();

That toZonedDateTimeISO(tz) crossing is the same one from the last lesson; the conversion is nothing new. What’s new is where the tz comes from: off the session, off the user, and always supplied, never defaulted.

The profile settings page lives at /settings/profile, a surface from the authentication unit named here but built later. It renders a timezone select whose options come from Intl.supportedValuesOf('timeZone'), which returns the platform’s list of known IANA names. Render each option with its current offset for legibility, like America/New_York (UTC−04:00), while the stored value stays the bare name.

The write goes through your canonical authedAction(role, schema, fn) wrapper, where the schema validates the submitted zone. Skip that validation and an invalid string doesn’t fail quietly at the write: it breaks on every read that touches it. Any toZonedDateTimeISO(badZone), and any Intl.DateTimeFormat handed that zone, throws RangeError: Invalid time zone specified . One value that should have been rejected at a single boundary then breaks every page that renders that user’s data. Validate at the edge and the column never holds a zone the runtime can’t resolve.

Here’s the quirk that bites the obvious approach. You’d validate by membership: is the submitted zone in the list Intl.supportedValuesOf gives you? But on Chromium and Node that list drops the Etc/* zones, so 'UTC' is usually missing. 'UTC' is your column’s own default, the fallback you ship, and the membership check rejects it, while the select built from that same list has no UTC option to begin with. The one zone you most need to accept is the one the obvious validator throws out.

The fix is to validate by acceptance, not membership. Stop asking whether a zone is in some list and ask the only question that matters: can the runtime use it? new Intl.DateTimeFormat('en-US', { timeZone: tz }) constructs for any zone it can format with and throws RangeError for any it can’t. A refine that wraps that construction in a try/catch accepts exactly the zones the runtime can use, 'UTC' included, and it can never drift from the platform’s true capability, because it is that capability. You could instead union 'UTC' into a membership allow-list and prepend it to the select, but that’s a manual patch you have to remember. Prefer acceptance.

const timeZoneSchema = z
.string()
.refine((tz) => Intl.supportedValuesOf('timeZone').includes(tz));

Looks airtight, isn’t. supportedValuesOf drops 'UTC' on Chromium and Node, so this refine rejects the column’s own default, and a select built from the same list has no UTC option at all.

The schema is a top-level Zod builder, safeParsed at the action boundary, with the authedAction wrapper lifting the session read and the parse out of the action body. Now write the validator yourself:

Write the validator from the green variant above: accept any zone the runtime can actually format with — 'UTC' included. The refine is stubbed to return false, so every row fails right now. Try constructing new Intl.DateTimeFormat('en-US', { timeZone: tz }) and return whether it succeeds; a real zone constructs fine, an unknown one throws RangeError. Don't reach for Intl.supportedValuesOf('timeZone').includes(...) — it drops 'UTC' and would reject the very fallback your column ships with.

Booting type-checker…
Test scenario Value
America/New_York "America/New_York"
Europe/Berlin "Europe/Berlin"
UTC (membership check rejects this) "UTC"
Mars/Phobos "Mars/Phobos"
Europe/Berlin-ish (typo, not a real zone) "Europe/Berlin-ish"

There’s a deeper move here, and it’s the takeaway worth carrying out of the whole lesson: don’t remember to handle the edge case, make the edge case unrepresentable. You could write “always pass the zone” on a sticky note and hope every call site obeys. Or you could write one helper, formatDate(value, { timeZone }), whose timeZone argument is required, and route all formatting through it. Now the no-argument bug isn’t discouraged, it’s impossible to express: you cannot call the wrapper without naming a zone, so no call site lets the runtime decide. The cure for “someone will forget” is never vigilance, it’s making forgetting fail to compile.

Interpreting scheduling inputs in the user’s timezone

Section titled “Interpreting scheduling inputs in the user’s timezone”

When a user says “remind me at 9 AM tomorrow,” “this is due by end of day,” or “ping me in 24 hours,” there is one clock they mean: theirs, not the server’s and not UTC’s. So every scheduling input and every deadline gets interpreted with user.timeZone as its calendar-and-clock context, then collapsed to a Temporal.Instant for storage. The zone does its work in the conversion and disappears into a plain UTC instant.

Here are the two computations you’ll write constantly.

const { user } = await requireOrgUser();
// "end of day, for this user"
const endOfToday = Temporal.Now.zonedDateTimeISO(user.timeZone)
.with({ hour: 23, minute: 59, second: 59 })
.toInstant();
// "9 AM tomorrow, for this user"
const remindAt = Temporal.Now.zonedDateTimeISO(user.timeZone)
.add({ days: 1 })
.with({ hour: 9, minute: 0, second: 0 })
.toInstant();

“Now,” but in the user’s zone, taken from the session. Watch the trap: that timeZone argument is optional, and omitting it defaults to the runtime’s zone (UTC on Vercel), the same lie as the no-argument Intl.DateTimeFormat(). The API won’t force you to pass it; the discipline does. Always supply it, and always the user’s.

const { user } = await requireOrgUser();
// "end of day, for this user"
const endOfToday = Temporal.Now.zonedDateTimeISO(user.timeZone)
.with({ hour: 23, minute: 59, second: 59 })
.toInstant();
// "9 AM tomorrow, for this user"
const remindAt = Temporal.Now.zonedDateTimeISO(user.timeZone)
.add({ days: 1 })
.with({ hour: 9, minute: 0, second: 0 })
.toInstant();

Set the wall-clock time the user actually means, end of day or 9 AM, on their calendar.

const { user } = await requireOrgUser();
// "end of day, for this user"
const endOfToday = Temporal.Now.zonedDateTimeISO(user.timeZone)
.with({ hour: 23, minute: 59, second: 59 })
.toInstant();
// "9 AM tomorrow, for this user"
const remindAt = Temporal.Now.zonedDateTimeISO(user.timeZone)
.add({ days: 1 })
.with({ hour: 9, minute: 0, second: 0 })
.toInstant();

“Tomorrow” on their calendar, wherever their next midnight falls, not the server’s.

const { user } = await requireOrgUser();
// "end of day, for this user"
const endOfToday = Temporal.Now.zonedDateTimeISO(user.timeZone)
.with({ hour: 23, minute: 59, second: 59 })
.toInstant();
// "9 AM tomorrow, for this user"
const remindAt = Temporal.Now.zonedDateTimeISO(user.timeZone)
.add({ days: 1 })
.with({ hour: 9, minute: 0, second: 0 })
.toInstant();

Collapse to the Temporal.Instant you store and schedule against. The user’s zone has done its job and is now baked into a single UTC instant.

1 / 1

Across a daylight-saving transition, these conversions are exactly where a wall clock skips or repeats an hour; the next lesson uses ZonedDateTime, the DST-aware type, to keep this pattern correct through spring-forward and fall-back.

Most of what you render or remind keys off user.timeZone, because the recipient reads it on their own clock. The exception is operations the company performs: those key off the organization’s clock, not any individual recipient’s.

Billing is the clean example. An invoice issued by a San Francisco company is dated by that company’s day. Issued at 11 PM Pacific on the 15th, its issue date is the 15th, even for a recipient in Tokyo for whom that instant is already the 16th. The issue date records when the company acted, and the company has one clock. So you mirror the column as organizations.timeZone and let a small resolver pick the zone for the operation at hand. The rule: user-facing rendering keys off the user’s zone; org-level business events (billing, issuance, company-wide scheduling) key off the org’s zone.

Sort these to feel where the line falls.

Sort each operation by whose clock it should use. The test is: whose business event is this — the person reading, or the company acting? Drag each item into the bucket it belongs to, then press Check.

User timezone The recipient reads it on their own clock
Org timezone A company-level event with one clock
Format the “created at” timestamp in a user’s activity feed
Send the monthly billing email at 9 AM (the recipient’s 9 AM)
Count down a “due in 3 days” reminder
Stamp the issue date printed on a company’s invoice
Set the close-of-business cutoff for a company-wide report run

Store timezone as user data, not configuration

Section titled “Store timezone as user data, not configuration”

The two dead-end branches of the opening walk share one principle: the user’s timezone is an attribute of the user, not an environment variable, a deployment region, or a per-build flag. A request that lands on your EU deployment tells you nothing about the human who made it, who could be a Spanish user on your US region or an American expat on the EU one. Timezone data follows the user, not the infrastructure.

Two footguns follow from that.

First, process.env.TZ is poison. Setting TZ, in a Docker base image or anywhere else, silently changes the meaning of every new Date(), every no-argument Intl.DateTimeFormat(), and every Temporal.Now.* in the application at once. On Vercel, TZ is a reserved variable you cannot set, because the platform pins the runtime to UTC on purpose. So the “every user renders in UTC” bug from the top of this lesson is the platform default working as designed. Keep the runtime at UTC; the user’s zone is data on a row, never a value in the process environment.

Second, never pin an offset, because tzdata changes. The IANA database ships several updates a year as countries move their daylight-saving dates. Store the IANA name and the platform’s bundled tzdata, kept current on Node and Vercel, resolves the right offset for you even after the rules change. Hard-code an offset and you freeze a rule the rest of the world is still editing.

People travel: someone signs up in New York, flies to Tokyo, and signs in from a new zone. This is also exactly where a naive “just auto-update it” quietly corrupts their scheduled work.

On sign-in, the client can send its current Intl.DateTimeFormat().resolvedOptions().timeZone. If it differs from the profile column, surface a non-blocking banner, such as “Looks like you’re in Tokyo. Update your timezone?”, and let the user decide. What you do not do is silently rewrite the column.

What happens when the zone does change, which future schedules re-register and how stored instants honor the intent they were created with, is the next lesson’s territory.