Skip to content
Chapter 83Lesson 3

Timezone on the profile

Storing each user's IANA timezone as a profile column, the data you read from the session and pass by hand into every formatter and scheduler.

Your SaaS will eventually need to do three things, and none of them work until you can answer one question:

  • The monthly billing email has to land at 9 AM in the customer’s morning, not at 9 AM in some server’s morning.
  • The “due in 3 days” reminder has to count three days from where the user is standing, not from where the server is.
  • The activity feed has to show each row’s timestamp the way that user would read a clock on their own wall.

Every one of those needs a timezone, and the question that decides whether they work or quietly break is: whose?

The previous two lessons built the floor underneath this. The first one set the storage shape: timestamptz for instants, Temporal.Instant in memory, ISO strings on the wire. The second finished the calendar-day domain: date columns, Temporal.PlainDate, and explicit crossings like toZonedDateTime({ timeZone, plainTime }) that name a zone out loud whenever wall-clock time enters the picture. Both lessons ended by pointing right here, at the edge where the timezone finally shows up. And both deferred the same question, which zone?, because it’s a decision big enough to own a lesson.

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 every scheduler you write. Not derived per request. Not guessed from the request. Stored.

That sounds almost too obvious to need a lesson. It isn’t, because there’s a tempting shortcut that looks correct on your laptop, ships without a single error, and formats every one of your users’ timestamps in the wrong timezone in production. The next forty minutes name that trap and build the column that makes it impossible to write. You already own the machinery this sits on top of: requireOrgUser() handing you { user, orgId, role }, the authedAction wrapper around your write paths, and the explicit-zone crossings from the last lesson. This lesson adds one column and the discipline for sourcing, validating, and consuming it.

Whose timezone? Three answers, one survivor

Section titled “Whose timezone? Three answers, one survivor”

Start with the decision, before any column or any code. Picking wrong here 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 line that caused it. There are three plausible places to get a user’s timezone. Two of them are traps, and the traps are tempting precisely because they’re less work.

Walk the decision below. Click into each shortcut and read where it dies. You already know the answer is “store it,” so the point is to feel exactly 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 sentence follows from it that is worth memorizing, because it names the exact line that walks the first trap into your codebase: never call the no-argument Intl.DateTimeFormat() in server code. It will lie to you, and it will lie quietly.

Why is it so quiet? Because of where the lie lives. On your laptop, Intl.DateTimeFormat().resolvedOptions().timeZone returns your real zone: America/New_York, Europe/Berlin, whatever your OS is set to. So you write the code, run it locally, see the timestamps look perfect, and ship. Then it runs on Vercel, where the runtime’s TZ is nailed to UTC, and the exact same call returns 'UTC' for everyone. No exception is thrown. No log line complains. Your German users simply start seeing every timestamp two hours off and their reminders firing at 2 AM, and the only signal you get is a confused support ticket. “Passes locally, wrong in prod, no error” is the most expensive bug shape there is, and the derive-per-request architecture 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”

So the timezone lives on the profile. Mechanically that’s a single text column on the users table, and the modeling decision inside it is the one place 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'. That default is the safe fallback for rows that predate the column, and for the moment when browser detection fails and gives you nothing.

The trap is to reach for an offset instead: to store -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 the string -05:00 describes a different real relationship to UTC depending on what month it is, which means that on its own it can’t tell you anything. Store the offset and you’ve thrown away the daylight-saving rules entirely. Every spring, when the clocks jump forward, every one of that user’s times silently shifts by an hour even though nobody changed a line of code.

Store the name instead, America/New_York, and the platform’s bundled timezone data does the work. Ask it for the offset at any specific instant and it consults the daylight-saving rules for that date and hands you the correct one. The picture below is the whole distinction in one frame: an IANA name is a rule that spans the year, and an offset is one frozen sample of that rule, 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'),

That second line, locale, is here for one reason: it travels with timeZone everywhere they go, so you’ll always see both on the profile, and 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 fully independent: a Berlin user can perfectly well read your app in en-GB. Both live on the profile, and this chapter owns the timezone half and leaves the locale half alone.

One more term is worth pinning down, because it’s what 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 any given date. It gets updated several times a year, because countries change their daylight-saving rules more often than you’d think. That’s exactly why you store the name and let the platform resolve it, a point we’ll come back to at the end.

A column that’s always 'UTC' because nothing ever sets it is useless. So how does a real zone get into it?

Exactly one place in the whole system actually knows the user’s timezone, and that’s the browser. The user’s operating system knows what zone they set, and the browser exposes it through, of all things, Intl.DateTimeFormat().resolvedOptions().timeZone. That’s the same call that lied to you in server code. The difference is entirely where it runs. In a Vercel function it reports the runtime’s zone, which is UTC. In the browser it reports the user’s own machine’s zone, which is the real answer. This is the one and only place the no-argument form is the correct tool, because here “the runtime” is the user’s own computer.

There are three sensible ways to capture it, and they trade 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. This is zero friction, since the user does nothing, and it’s right the vast majority of the time. This is the default.
  • An onboarding picker. A step after sign-up where the user explicitly chooses their zone from a list. This has more friction but is fully explicit. Reach for it when getting the zone exactly right matters more than a frictionless signup.
  • A 'UTC' fallback plus a first-sign-in prompt. The cheapest migration path for accounts that already exist without a zone: default them to 'UTC', then nudge them to confirm next time they sign in.

The resolution chain in practice is to detect from the browser, fall back to 'UTC' if detection comes back empty, and let the user fix it on their profile page later. And here’s the framing that keeps you honest about what that detected value is: it’s user-asserted, not authoritative. It’s a sensible default the user owns and can correct, not ground truth you’d bet the billing run on.

This is the same instinct as the last lessons’ “never trust the client clock,” just 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 is doing real work here: it guarantees this code runs in the browser, which is the only context where that Intl.DateTimeFormat() call tells the truth. The detected zone rides into the sign-up Server Action as a hidden form field, named timeZone to match the action’s schema key.

But a hidden input is still user-supplied input, which makes it untrusted: anyone with devtools can edit it to whatever they like. So before this value is allowed anywhere near the column, it gets validated on the server. That’s the next section, and it has a genuinely surprising twist.

Once the column is populated, code needs to get the zone back out. There are two seams for that, and both of them are 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. So in any authenticated context, the zone is one destructure away. Reach for this first.

The second is a small helper, getCurrentUserTimeZone(), living in lib/user-time.ts. Picture a Server Component buried six levels deep that needs the zone to format one timestamp. Threading timeZone down through every intervening component as a prop is the kind of plumbing that rots. Instead, a thin React-cached read fetches it directly. Crucially, 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 non-negotiable is the same: the zone gets passed 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 itself is nothing new. What’s new is everything around it: where the tz comes from, which is off the session and off the user, and the rule that it is always supplied, never defaulted. The conversion was already explicit; now the source of its argument is too.

The profile settings page is a /settings/profile surface from the authentication unit, built fully later in the project and named here but not constructed. It renders a timezone select. The option list comes from Intl.supportedValuesOf('timeZone'), a standard API (Node 18 and up, and a baseline browser feature since 2022) that returns the platform’s list of known IANA names. You’d typically 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, and the schema validates the submitted zone. This validation is not optional, for a concrete reason: an invalid string in that column doesn’t fail quietly. It breaks on every read that touches it. Any toZonedDateTimeISO(badZone), and any Intl.DateTimeFormat handed that zone, throws RangeError: Invalid time zone specified . A value that should have been rejected at one write boundary instead 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.

Now the surprising part, a real and current quirk that bites people who do the obvious thing. You’d reasonably validate by checking membership: is the submitted zone in the list Intl.supportedValuesOf('timeZone') gives me? That list is the source of truth for real zones, isn’t it? Except Intl.supportedValuesOf on Chromium and Node drops the Etc/* zones, which means 'UTC' is usually not in the list. Consider what that means. 'UTC' is your column’s own default. It’s the fallback you ship. And the naive membership check rejects it, while the select you built from that same raw list silently has no UTC option in the dropdown. The one zone you most need to accept is the one the obvious validator throws out.

There are two clean fixes, and one of them is clearly better.

The robust fix is to validate by acceptance, not membership. Stop asking “is this zone in some list” and start asking the only question that actually matters: can the runtime use this zone? The runtime answers that itself. new Intl.DateTimeFormat('en-US', { timeZone: tz }) constructs fine 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 validates exactly the set of zones the runtime can really use, 'UTC' included, and it can never drift from the platform’s true capability, because it is the platform’s true capability. The alternative, if you insist on membership, is to explicitly union 'UTC' (and any other Etc/* zones you support) into the allow-list and prepend it to your select. That works, but it’s a manual patch over a list that was wrong, and you have to remember it. 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. This is the same five-seam shape every write in this project follows. 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 available here, and it’s the takeaway worth carrying out of the whole lesson. The last lesson made the case for a particular instinct: don’t remember to handle the edge case, make the edge case unrepresentable. The same idea applies to the Vercel-UTC bug. 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 your 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 there is no call site where the runtime gets to decide. The cure for “someone will forget” is never vigilance, it’s making forgetting fail to compile.

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

Here are the two computations you’ll write constantly. Step through them. The discipline is the same one from before, now landing on real scheduling math.

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. Omit it and zonedDateTimeISO() defaults to the runtime’s zone (UTC on Vercel), exactly the same lie as the no-argument Intl.DateTimeFormat(). The API doesn’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, whether 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, which is whenever 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 finished its job in the conversion and is now baked into a single UTC instant.

1 / 1

Notice the echo in that first step: Temporal.Now.zonedDateTimeISO(tz) has the same optional-argument trap as Intl.DateTimeFormat(). Leave the zone off and it silently falls back to the runtime, UTC on Vercel, and you’re back in the same quiet bug. The API won’t stop you; the discipline does. Always pass it, and always the user’s zone.

Here is one thing to plant for the next lesson without unpacking it now. Across a daylight-saving transition, these conversions are the exact spot where a wall clock skips an hour forward or repeats an hour backward. ZonedDateTime is the type built to handle that ambiguity correctly, and the next lesson takes this very pattern, Temporal.Now.zonedDateTimeISO(tz) into a stored instant, and uses it to schedule recurring jobs that survive spring-forward and fall-back. For now, just know that ZonedDateTime is the daylight-saving-aware type, and this conversion is where its awareness earns its keep.

Almost everything you render or remind keys off user.timeZone, because the recipient reads it on their own clock. But not quite everything. Some operations are events the company performs, and those key off the organization’s clock, not any individual recipient’s.

The clean example is billing. An invoice issued by a San Francisco company is dated by that company’s day. If it’s issued at 11 PM Pacific on the 15th, the issue date is the 15th, even for a recipient in Tokyo for whom that instant is already the 16th. The issue date is a fact about when the company acted, and the company has one clock. So you mirror the column as organizations.timeZone, and a small resolver picks the right zone for the operation at hand. The rule to internalize is short: user-facing rendering keys off the user’s zone, and org-level business events (billing, issuance, company-wide scheduling) key off the org’s zone. Most of your code lands on the user; a handful of genuinely company-level events land on the org.

Sort these to feel where the line falls. The test for each one is a single question: whose business event is this, the person reading the screen, or the company taking the action?

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

Step back and look at what tied the two dead-end branches of the opening walk together, because it’s a single principle worth stating plainly: the user’s timezone is an attribute of the user, not an environment variable, not a deployment region, and not a per-build flag.

The canonical version of this mistake is treating the deployment region as a stand-in for timezone: “the EU deployment serves European customers, so default everyone on it to Europe/Brussels.” It falls apart the moment you think about who actually uses your app. A Spanish user can hit your US deployment. An American expat lives on the EU one. The infrastructure a request happens to land on tells you nothing about the human who made it. Data follows the user; it does not follow the infrastructure. This is the geo-IP dead end from the opening walk, restated as a law.

Two consequences fall straight out of that law, and both are real footguns rather than hypotheticals.

The first: process.env.TZ is poison. Set TZ, say in a Docker base image or anywhere else in your environment, and you’ve silently changed the meaning of every new Date(), every no-argument Intl.DateTimeFormat(), and every Temporal.Now.* in the entire application at once. On Vercel, TZ is a reserved environment variable that you cannot set through project settings, precisely because the platform pins the runtime to UTC on purpose. So the “every user renders in UTC” bug from the top of this lesson isn’t a freak accident; it’s the platform default behaving exactly as designed. Keep the runtime at UTC and never fight it. The user’s zone is data on a row, never a value in the process environment.

The second: tzdata changes, so never pin an offset. The IANA database ships several updates a year as countries move their daylight-saving dates around. Store the IANA name and the platform’s bundled tzdata, kept current on Node and Vercel, resolves the right offset for you automatically, even after the rules change. The instant you hard-code an offset to “fix” something, you’ve frozen a rule that the rest of the world is still editing. Don’t ship a hand-rolled timezone table, and don’t pin an old tzdata. Store the name and let the platform keep up.

People travel, and travel is common enough that you should plan for it: someone signs up in New York, flies to Tokyo, and signs in from a new zone. This is worth handling, and it’s also exactly the place where a naive “just auto-update it” quietly corrupts their scheduled work.

The pattern is gentle. 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, like “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 actually happens when the zone does change, meaning which future schedules re-register and how already-stored instants honor the intent they were created with, is the next lesson’s territory. For now, detect the drift, offer the fix, and keep your hands off the column until the user says yes.

The edge is real now. Trace the layers the chapter has been building: the first lesson set storage, the second finished the calendar-day domain, and this one installed the edge, the user’s timezone as a profile column, seeded from the browser at sign-up, validated at the write edge so a bad string never lands, read from the session, and passed by hand into every formatter and every scheduler. Storage, domain, edge: all three layers are standing.

The next lesson takes this exact user.timeZone column and this exact Temporal.Now.zonedDateTimeISO(tz) pattern into recurring jobs that survive a daylight-saving transition: a “9 AM weekday in America/New_York” schedule that doesn’t drift when the clocks change, built on a named IANA zone rather than a fragile offset. After that comes the full Temporal arithmetic surface. And in the chapter after this one, the locale half of the profile finally gets wired up: Intl.DateTimeFormat and next-intl consume the very column you just built, though the formatting itself lives there, not here.