Navigation

Date and time display

A date rendered by a component is formatted twice: once on the server, once again when the client hydrates. Those two runs do not agree. The server formats in the container's locale and timezone (UTC on Azure), the browser in the reader's — so formatDate(...), toLocaleDateString() and useTimeAgo() in a template produce different text on each side. Vue reports that as Hydration completed but contains mismatches, silently re-renders the subtree, and the reader is left looking at the server's clock rather than their own.

Anything a reader sees is a <NuxtTime>. Formatting a date any other way inside a .vue file is a vue/no-restricted-syntax error. The one exception is the client-rendered message list, below.

<NuxtTime :datetime="post.createdAt" relative />
<NuxtTime :datetime="resource.createdAt" day="numeric" month="short" year="numeric" />

Why it is hydration-safe

<NuxtTime> renders the server's own formatting into the html, but it also renders the machine-readable instant and the formatting options as attributes, and ships a tiny script that reformats the text from those attributes before Vue hydrates. So the server's text never survives to be compared: by the time Vue hydrates, the DOM already holds the browser's own string, computed against one page-wide frozen now, which is exactly what Vue's own render then produces.

flowchart LR
  server["server render — time datetime=ISO data-*=options, text in the server's locale"] --> html["html sent to the browser"]
  html --> prehydrate["onPrehydrate script — Intl in the reader's locale/timezone, window._nuxtTimeNow"]
  prehydrate --> text["textContent rewritten"]
  text --> hydrate["Vue hydrates — computes the same string, matches"]
  hydrate --> tick["relative mode re-renders each second"]

Options, not format strings

Formatting is Intl.DateTimeFormat options passed as attributes (weekday, year, month, day, hour, minute, timeZoneName, dateStyle, timeStyle), so the shape of the output is chosen by the reader's locale rather than by a pattern we wrote. relative switches to Intl.RelativeTimeFormat for time-ago text and self-ticks once a second per instance. title is not a localized tooltip: the boolean form renders toISOString(), and the prehydrate script rewrites only the element's text, never its title, so a reader hovering gets UTC machine text. Pass title a string when an exact instant is genuinely wanted; otherwise leave it off.

A format shared by more than one call site is one constant of attributes, spread in — RESOURCE_DATE_TIME_ATTRIBUTES beside its string counterpart in app/services/resource/constants.ts:

<NuxtTime :="RESOURCE_DATE_TIME_ATTRIBUTES" :datetime="resource.createdAt" />

A plain date — a calendar's day, which names no instant — is handed over as its ISO date with time-zone="UTC", which renders exactly the day it names wherever the reader is; read in the reader's own zone, midnight UTC is the day before for half the world (calendar).

A <NuxtTime> is a component, so it cannot live inside a prop string. A subtitle or a sentence that embeds a time is written as slot content with the time in inline flow, never assembled with template literals in script.

Where a format string still belongs

formatDate formats data, not display: filenames, CSV exports, the value accessors a data table sorts on, and the cell value a date input writes back. It is the repo's own token formatter — the dayjs format subset the repo actually writes, over Temporal — and parseDate is its strict inverse over the numeric tokens and the offset, which is what a sheet's date column round-trips a cell through. A format carrying month or weekday names, an ordinal or a meridiem is display-only, and parseDate refuses it rather than guessing. The date logic underneath is Temporal directly, through the day-level helpers in @esposter/shared (getStartOfDay, checkIsSameDay, checkIsToday); a duration is a Temporal.Duration, never a library's. The lint rule only covers .vue files; services and server code format freely.

The one display exception is the message list. Its labels (Yesterday at 14:03, the day divider, the compact 24-hour gutter clock) branch on the reader's own day boundary, which no single <NuxtTime> expresses, and its 24-hour format has to stay identical across three surfaces. That is safe because /messages/** is client-rendered — like /resource-explorer/**, it is auth-gated with no SEO value, so there is no server render to disagree with. The labels live in app/util/date/, outside any component.

Themes have the same failure mode

The same "server guesses, client knows" split hits the theme: the system's scheme is a media query only the browser can read. How the first paint is still right for a system reader is the UI library's to say.

Key files

FileRole
packages/configuration/eslint/overrides/vueRules.jsThe vue/no-restricted-syntax rule, and the <time> element ban
apps/web/app/services/resource/constants.tsRESOURCE_DATE_TIME_ATTRIBUTES and its string counterpart
apps/web/app/util/date/The message-list labels, the one place a display format is hand-written
apps/web/shared/util/date/formatDate/parseDate and the token map they share
apps/web/configuration/routeRules.tsThe client-rendered app surfaces
apps/web/app/components/Nuxt/Theme.vueSystem-theme resolution, the theme half of the same problem
apps/web/app/composables/ui/useSelectUiTheme.tsThe head rule that paints a system reader's first response dark

Details

Command palette

Keyboard shortcuts