Skip to content
Chapter 85Lesson 3

Format dates in profile tz and currency from data

Every invoice date should render in the wall-clock of the person looking at it, and every amount in the currency the invoice was actually issued in — formatted the way the viewer’s locale writes numbers.

Last lesson left the list speaking three languages: every UI string flows through t(), the headers and status labels reflow per locale, and the “N invoices” counter picks the right CLDR plural category. But the two cells that carry the actual data are still wrong. The amount prints as a raw USD 1234.56 — the currency code glued in front of an unformatted decimal — and the dates come straight off toLocaleDateString() with no timezone, which means they render in whatever clock the runtime happens to sit in. On your laptop that’s your local zone; on Vercel it’s UTC. Same code, different output depending on where it runs. That is exactly the bug this lesson exists to kill.

When you finish, /invoices as an (en-US, America/New_York) user shows USD amounts as $1,234.56 with dates in that user’s EDT or EST. Switch to French and the same data reflows: a EUR invoice reads 1 234,56 €, a USD invoice reads 1 234,56 $ — same dollars, French formatting, the narrow $ symbol. And in the inspector’s DST panel, switching the viewer to Europe/London renders the seeded 2026-07-01T18:00:00Z as 7:00 PM BST and 2026-01-01T18:00:00Z as 6:00 PM GMT — two different wall-clock hours from the same UTC moment, with not a single line of DST code anywhere in your work.

The /invoices list for an (en-US, America/New_York) viewer — USD amounts as `$1,234.56`, New-York-zone dates, and the relative-due column.

There are no new dependencies, no environment variables, and nothing to install — the app has been running since the project overview. This is three small edits to files you already touched last lesson.

This is the lesson where one idea does all the work: the right wall-clock and the right currency symbol are both decided by data you hand the formatter, never by the machine the code runs on. A formatter with no timeZone falls back to the runtime zone, and a runtime zone is an accident of deployment. A currency symbol inferred from the viewer’s locale would tell a French user their USD invoice is in euros. Both failures pass every test ever written without a fixture that spans timezones or currencies — which is why teams ship them to production and discover them from a confused customer, not a red build.

The shape that prevents this is a single formatting seam. The viewer’s timezone is a profile field on their account (you met getCurrentUserTimeZone() back in Timezone on the profile), and you read it once on the server in the page, then thread it into the client table as a prop. You read it in the page rather than in the request config because the config has to stay prerender-safe — it can’t touch the session, or the static locale shell stops building (that constraint is the whole reason the config returns only { locale, messages, formats }; last lesson’s request-config walkthrough has the detail). Every format.dateTime call is then handed that timeZone explicitly. Because the stored value is a Temporal.Instant and the zone is a real IANA name, the formatter is DST-aware for free: the same July and January instants resolve to different wall-clock hours in London with no DST branch you have to write or maintain.

Currency works the same way. The currency code lives on the invoice row (currency: row.currency) because the invoice was issued in a specific currency and that fact never changes with who’s reading it. So the code rides at the call site as data. The display style — whether you show $, US$, or USD — is a presentation decision, so it lives in the shared preset in formats.ts as currencyDisplay: 'narrowSymbol'. Keeping those two on opposite sides of the seam is the point: a designer-driven “show the full symbol everywhere” change is one edit to the preset, while the per-row currency is never something a presentation layer gets to override.

A few things are deliberately out of scope. The relative-due column reads “in 3 days” / “il y a 5 jours”, but it does not tick live — this is a server-rendered list with fresh data on every navigation, so a per-minute client island would be machinery with no payoff (reach for one only when the time genuinely has to update on screen). Amounts above Number.MAX_SAFE_INTEGER are named but not handled — your divide-by-100 is correct for every real invoice and the safe-integer ceiling is the boundary to know, not to build for here. And the only Temporal arithmetic you write is the single day-delta for the due column; everything past that one until() call belongs to Arithmetic with Temporal. Hold the discipline that makes all of this stick: zero Date.prototype.toLocaleString and zero raw Intl.* calls anywhere inside app/[locale]/ — formatting goes through the useFormatter seam, and the presets stay centralized in formats.ts.

Every invoice date renders in the viewer’s profile timezone — an America/New_York user sees EDT in summer and EST in winter, never UTC.
tested
The two DST-spanning instants render the correct wall-clock for a Europe/London viewer — 2026-07-01T18:00:00Z as 7:00 PM BST and 2026-01-01T18:00:00Z as 6:00 PM GMT — and as 2:00 PM EDT / 1:00 PM EST for an America/New_York viewer.
tested
Each amount renders in the invoice’s stored currency formatted for the viewer’s locale: the same EUR datum shows 1 234,56 € in fr-FR; a USD datum shows $1,234.56 in en-US and 1 234,56 $ in fr-FR with the narrow symbol.
tested
The relative-due column reads naturally per locale: in 3 days / 5 days ago in en-US, dans 3 jours / il y a 5 jours in fr-FR.
tested
Switching the profile timezone in the inspector shifts the wall-clock of every date cell with no change to the underlying data.
untested
The (fr-FR, Pacific/Auckland) user renders French strings with dates in NZDT/NZST — locale and timezone are independent fields and the combination works with no combination-specific code.
untested
The structural audit stays green: no Date.prototype.toLocaleString and no raw Intl.* inside app/[locale]/.
untested

Three files change, all of them files you already opened last lesson, each carrying a TODO(L3) marker: src/i18n/formats.ts, the invoices page.tsx, and the invoices table.tsx. Build it against the brief above and the tests before you open the solution.

Reference solution and walkthrough

formats.ts already carries dateTime.short, dateTime.withTime, and number.compact from last lesson. The only addition is one currency preset.

src/i18n/formats.ts
import type { Formats } from 'next-intl';
// Shared formatter presets, referenced by name at `format.dateTime`/`format.number`
// call sites so a UI-wide change is one edit. S2 adds `number.currency`
// (narrow-symbol). There is NO `relativeTime` key — next-intl's `Formats` type has
// no slot for it (only dateTime/number/list/displayName), so adding one fails `tsc`.
export const formats = {
dateTime: {
short: { dateStyle: 'medium' },
withTime: { dateStyle: 'medium', timeStyle: 'short' },
},
number: {
compact: { notation: 'compact' },
// The narrow-symbol display lives here so a UI-wide currency tweak is one
// edit; the `currency` code stays at the call site because it is data on the
// invoice row, not a presentation choice. No `relativeTime` key — next-intl's
// `Formats` type has only dateTime/number/list/displayName.
currency: { style: 'currency', currencyDisplay: 'narrowSymbol' },
},
} as const satisfies Formats;

narrowSymbol is what renders $ instead of US$ and instead of EUR — the other two options are 'name' (“US dollars”) and 'code' (“USD”), both too heavy for a dense table cell. Notice the preset carries the style but no currency field. That’s deliberate: the currency code is data on each row, so it can’t be baked into a shared preset — it arrives at the call site instead.

One thing that looks like an omission but isn’t: there is no relativeTime key here. next-intl’s Formats type only has slots for dateTime, number, list, and displayName — adding a relativeTime key fails tsc outright. Relative-time options ride at the call site, which you’ll see in the table.

Reading the timezone and the day delta on the server

Section titled “Reading the timezone and the day delta on the server”

The page is a Server Component. It already reads the session, runs listInvoices, and renders the count through the ICU plural message. The TODO(L3) work is to read the viewer’s timezone and compute the per-row due delta, then thread both into the client table.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

Read the viewer’s profile timezone once, here on the server. It’s a profile field on the account, not a request header — getCurrentUserTimeZone() reads it off the session. The page reads it and threads it down because the request config can’t (the config stays prerender-safe so the static locale shell builds).

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

Capture one stable clock for the whole render. Reading it once — after the dynamic tz read, so the clock trails a request-time source and stays Cache Components safe — means the relative-due column anchors to a single now and never drifts between the server render and the client paint.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

The one piece of Temporal arithmetic in this lesson. today is Temporal.Now.plainDateISO(tz) — the calendar date it is right now in the viewer’s zone — and until() gives the duration to each row’s due date. largestUnit: 'day' is mandatory: omit it and the duration splits into months and days, so .days returns only the leftover days and the column lies. With it, you get the true integer day count.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

Build a plain Record<id, number> map of the deltas, one entry per row. A plain serializable object crosses the server-to-client boundary cleanly; a Temporal.Duration would not.

const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
return (
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
);

Thread it all into the client table. toInvoiceRow projects each row to a serializable shape — createdAtMs epoch millis and a dueDateISO string — because a Temporal.Instant can’t cross the RSC-to-Client boundary. The tz, the stable now, and the delta map ride alongside so the client formatter has everything it needs.

1 / 1

Here is the full page.tsx for reference — the imports and the carry-in list rendering from last lesson, plus the TODO(L3) block.

src/app/[locale]/(app)/invoices/page.tsx
16 collapsed lines
import { notFound } from 'next/navigation';
import { hasLocale } from 'next-intl';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { SearchParams } from 'nuqs/server';
import { ActiveFilterChips } from '@/app/[locale]/(app)/invoices/active-filter-chips';
import { Pagination } from '@/app/[locale]/(app)/invoices/pagination';
import { InvoicesTable } from '@/app/[locale]/(app)/invoices/table';
import { Toolbar } from '@/app/[locale]/(app)/invoices/toolbar';
import { ViewTabs } from '@/app/[locale]/(app)/invoices/view-tabs';
import { routing } from '@/i18n/routing';
import { listInvoices, toInvoiceRow } from '@/lib/invoices/queries';
import { invoiceListSearchParamsCache } from '@/lib/invoices/search-params';
import { Temporal } from '@/lib/temporal';
import { getCurrentUserTimeZone } from '@/lib/user-time';
import { getSession } from '@/server/session';
22 collapsed lines
type PageProps = {
params: Promise<{ locale: string }>;
searchParams: Promise<SearchParams>;
};
const InvoicesPage = async ({ params, searchParams }: PageProps) => {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
const t = await getTranslations('invoices.list');
const parsed = await invoiceListSearchParamsCache.parse(searchParams);
const session = await getSession();
const { rows, nextCursor, hasPrev } = listInvoices({
orgId: session.orgId,
role: session.role,
...parsed,
});
// The viewer's profile tz drives every wall-clock cell; read it once. A stable
// per-render `now` (read after the dynamic tz, so the clock trails a request
// source — Cache Components safe) anchors the relative-due column. The day
// delta is integer days between today (in the profile tz) and the calendar
// due date — the lesson's single Temporal arithmetic call.
const tz = await getCurrentUserTimeZone();
const nowMs = Date.now();
const today = Temporal.Now.plainDateISO(tz);
const dueInDaysById = Object.fromEntries(
rows.map((row) => [
row.id,
today.until(row.dueDate, { largestUnit: 'day' }).days,
]),
);
19 collapsed lines
return (
<div data-testid="invoices-page" className="space-y-4">
<h1 className="text-xl font-semibold">{t('title')}</h1>
<div
data-testid="invoices-grid"
className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]"
>
<div data-testid="invoices-list" className="space-y-4">
<p
data-testid="invoice-count"
className="text-sm text-muted-foreground"
>
{t('count', { count: rows.length })}
</p>
<ViewTabs parsed={parsed} role={session.role} />
<Toolbar parsed={parsed} />
<ActiveFilterChips parsed={parsed} />
{/* Project rows to a serializable shape: Temporal instances can't
cross the RSC → Client boundary. The tz, the stable `now`, and the
per-row day delta ride alongside so the client formatter renders
the right wall-clock and relative phrase. */}
<InvoicesTable
rows={rows.map(toInvoiceRow)}
view={parsed.view}
role={session.role}
timeZone={tz}
nowMs={nowMs}
dueInDaysById={dueInDaysById}
/>
16 collapsed lines
<Pagination
cursor={parsed.cursor}
nextCursor={nextCursor}
hasPrev={hasPrev}
/>
</div>
<aside className="rounded-lg border p-4 text-sm text-muted-foreground">
{t('selectPrompt')}
</aside>
</div>
</div>
);
};
export default InvoicesPage;

The table is the client component, so this is where useFormatter actually runs. It’s already wired for useTranslations from last lesson; the TODO(L3) work adds const format = useFormatter();, a tiny addDays helper, and three formatted cells.

const format = useFormatter();
const now = new Date(nowMs);
// Created moment in the viewer's profile tz.
{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}
// Relative due date against the stable per-render now.
{format.relativeTime(addDays(now, dueInDaysById[row.id] ?? 0), {
now,
unit: 'day',
})}
// Amount: minor units / 100, the narrow-symbol preset, the row's own currency.
{format.number(row.amountMinor / 100, 'currency', {
currency: row.currency,
})}

useFormatter() reads the locale and the shared formats presets out of context, so it’s constructed once and reused for every cell. Reconstruct now from the nowMs the server handed down so the relative column anchors to the exact same instant the server used — no drift between paints.

const format = useFormatter();
const now = new Date(nowMs);
// Created moment in the viewer's profile tz.
{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}
// Relative due date against the stable per-render now.
{format.relativeTime(addDays(now, dueInDaysById[row.id] ?? 0), {
now,
unit: 'day',
})}
// Amount: minor units / 100, the narrow-symbol preset, the row's own currency.
{format.number(row.amountMinor / 100, 'currency', {
currency: row.currency,
})}

The date cell. The row arrived as createdAtMs (a Temporal.Instant can’t cross to the client), so reconstruct a Date and hand it to format.dateTime with the explicit timeZone. That timeZone argument is the whole lesson: it’s what makes 18:00Z resolve to 2:00 PM in New York and 7:00 PM in London. Drop it and the formatter falls back to the runtime zone — UTC on Vercel — and silently formats everyone’s data in the wrong clock.

const format = useFormatter();
const now = new Date(nowMs);
// Created moment in the viewer's profile tz.
{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}
// Relative due date against the stable per-render now.
{format.relativeTime(addDays(now, dueInDaysById[row.id] ?? 0), {
now,
unit: 'day',
})}
// Amount: minor units / 100, the narrow-symbol preset, the row's own currency.
{format.number(row.amountMinor / 100, 'currency', {
currency: row.currency,
})}

The due cell. The server already computed the integer day delta, so addDays(now, delta) builds the target date and format.relativeTime phrases the gap. next-intl applies CLDR numeric: 'auto' internally, which is what gives you “in 3 days” / “5 days ago” in English and “dans 3 jours” / “il y a 5 jours” in French — never a string you assembled by hand. The ?? 0 is a safe fallback if a row somehow has no delta.

const format = useFormatter();
const now = new Date(nowMs);
// Created moment in the viewer's profile tz.
{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}
// Relative due date against the stable per-render now.
{format.relativeTime(addDays(now, dueInDaysById[row.id] ?? 0), {
now,
unit: 'day',
})}
// Amount: minor units / 100, the narrow-symbol preset, the row's own currency.
{format.number(row.amountMinor / 100, 'currency', {
currency: row.currency,
})}

The amount cell. Money is stored in minor units (cents), so divide by 100 at display. The string 'currency' names the preset from formats.ts (which carries narrowSymbol), and currency: row.currency supplies the code from the row. That split is the seam: the style is shared config, the currency is per-row data.

1 / 1

A couple of decisions worth making explicit, since the tests don’t reach them.

The addDays helper lives at module scope above the component, not inline, because it’s a pure date-shift with no React in it — keeping it out of the render keeps the cell readable:

// Shift a `Date` by whole days — the relative-due anchor builds the target date
// `now + days` so `format.relativeTime` reads the delta against the stable `now`.
const addDays = (now: Date, days: number): Date =>
new Date(now.getTime() + days * 86_400_000);

The archived-on line — the small Archived {date} caption that shows under a row in the archived view — moves onto the seam too. Last lesson it was still a raw toLocaleDateString(); now it goes through format.dateTime with the same timeZone, so it can’t become the one place a UTC date sneaks back in:

{format.dateTime(new Date(row.archivedAt), {
dateStyle: 'medium',
timeZone,
})}

That’s the discipline behind requirement 7. The rule “every dateTime call gets an explicit timeZone” is the kind of thing you’d enforce with a lint rule on a real team — not because developers are careless, but because the failure mode is invisible until a user in another zone complains, and by then it’s in production. Routing all formatting through useFormatter and forbidding raw Intl.* inside app/[locale]/ is what makes that rule mechanical to check.

And here is the punchline for requirements 5 and 6: nothing in any of these three files knows or cares which locale or timezone is active. The page reads whatever the session says, the table formats with whatever it’s handed. So a (fr-FR, Pacific/Auckland) user — French strings, New Zealand clock — works with zero combination-specific code, and flipping the timezone in the inspector reflows every date cell without touching a single byte of invoice data. That’s the decoupling falling out for free, which is exactly what you want: the combinations you never explicitly tested are the ones the architecture has to cover.

To make the central bug visible before you ever hit it, here is the date cell with and without the timeZone argument:

{format.dateTime(new Date(row.createdAtMs), {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
})}

Renders in the viewer’s clock. 18:00Z becomes 2:00 PM in New York and 7:00 PM in London — and the seeded January instant correctly shifts to 1:00 PM / 6:00 PM because the IANA zone knows about DST.

For the Temporal.Instant and Temporal.PlainDate codecs that put createdAt and dueDate into the store in the first place, see Storage, domain, edge, and for the profile-timezone seam, Timezone on the profile. For why useFormatter constructs once and how the Intl.* family sits underneath next-intl, see The Intl.* formatter family.

Run the lesson’s tests:

pnpm test:lesson 3

A green run confirms the four behaviors the suite reaches: dates rendering in the viewer’s profile zone (the New York EDT/EST pair), the DST-spanning London fixture (7:00 PM BST vs 6:00 PM GMT, plus the New York side), the currency-by-data assertions (USD and EUR across en-US and fr-FR, narrow symbol), and the relative-due phrasing in both languages. The tests render the real client table inside a NextIntlClientProvider and read the visible text of each value cell — they assert wall-clock and currency output, never how you wired the formatter.

pnpm test:lesson 3
✓ Requirement 1 — invoice dates render in the viewer profile timezone (1)
✓ Requirement 2 — the DST-spanning instants render the right wall-clock (2)
✓ Requirement 3 — amounts render in the stored currency for the viewer locale (2)
✓ Requirement 4 — the relative-due column reads naturally per locale (2)
Test Files 1 passed (1)
Tests 7 passed (7)

The rest you confirm by hand — these are the behaviors a node-environment test can’t see, plus the deliberate-misuse rehearsals that make the seam’s value concrete. Walk the inspector and the running app and tick each off:

/invoices as the (en-US, America/New_York) user shows USD as $1,234.56 and dates in EDT/EST; switching to fr-FR reflows the same data to 1 234,56 € (EUR) and 1 234,56 $ (USD) with no data change.
untested
In the inspector DST panel as a Europe/London user, the July instant shows 7:00 PM BST and the January instant 6:00 PM GMT; switching to America/New_York shows 2:00 PM EDT and 1:00 PM EST.
untested
The inspector currency-by-data panel’s nine cells (three currencies × three locales) are each consistent — the same amount, the same currency tag, locale-specific formatting.
untested
The relative-due column reads naturally in both locales across future and past dates.
untested
Switching to the (fr-FR, Pacific/Auckland) user renders French strings with dates in NZDT/NZST — locale and timezone are independent and the combination works with no combination-specific code.
untested
Rehearse the bug: as the Europe/London user, temporarily delete timeZone from the table’s date cell format.dateTime call. The date column now ignores the viewer’s zone and falls back to the runtime clock, so the July and January rows no longer read 7:00 PM BST / 6:00 PM GMT — on Vercel both would collapse to their UTC 6:00 PM. Revert it; that one missing argument is the production bug the seam exists to prevent.
untested
Rehearse the other bug: temporarily hard-code currency: 'USD' at the amount call site. Every row now renders as dollars regardless of the invoice’s actual currency. Revert it — currency is data on the row, never a constant.
untested

With dates and money on the seam, the invoices surface is fully localized for its viewers. One slice remains: the public SEO shape — hreflang, the locale-specific canonical, and per-locale OG images — which the next lesson wires onto the marketing pages.