Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Nuxt & VueLesson

Hydration

Each Vue component's setup code (the code in its <script setup> block) runs twice on a page the server renders: once on the server to write the HTML, and again in the browser to take that HTML over. The browser's first render has to match the server's. Only after that may it show something the server couldn't know, such as the visitor's time zone.

Know Vue and server rendering already? Skip to In Terrarium.

Outside the box · General

The concept

With server rendering, the server runs each component's setup code and renders HTML. onMounted, onUpdated and onUnmounted never run there. The browser shows that HTML, loads the same code and runs it again, but instead of building new elements it hydrates: it walks the HTML the server sent, matches it to its own render and attaches event listeners. When the two renders differ, that is a hydration mismatch. Random values, the current time, the time zone or locale, and browser-only APIs such as localStorage are the usual causes. Vue replaces mismatched text and any bound attribute with the browser's value, but leaves a mismatched class or style as the server wrote it. In development it warns about each; a production build logs one console error, Hydration completed but contains mismatches., and leaves out the details.

The fix is to give both renders the same input, and to change the page only in onMounted, once hydration is done. Nuxt's useState(key, init) carries a value across: the server runs init, sends the result in the page's payload, and the browser starts from it instead of running init again. Every component that calls useState with the same key shares one value: on the server for one request, in the browser for the whole visit. Nuxt's hydration guide lists the common mismatches with fixes.

Full referenceVue docs: Server-Side Rendering

Looking inside

In Terrarium

Nuxt renders every page on the server and the browser hydrates it. Tinkerfund shows each exact date in the visitor's own time zone, which only the browser knows. Its date component renders UTC on the server and in the browser's first render, then switches:

layers/tinkerfund/app/components/tinkerfund/Time.vue ↗1–17as of
1<script setup lang="ts">
2// The exact date is in the visitor's own time zone (issue #1364), which only
3// the browser knows: the server renders UTC and the browser swaps it after mount.
4// Without `text`, the exact date is the visible text; with it, a hover title.
5const props = defineProps<{ at: number; text?: string }>()
6const locale = useTinkerfundLocale()
7const mounted = ref(false)
8onMounted(() => (mounted.value = true))
9const exact = computed(() => {
10  const date = new Date(props.at).toLocaleString(locale.value, { dateStyle: 'long', timeStyle: 'short', timeZone: mounted.value ? undefined : 'UTC' })
11  return mounted.value ? date : `${date} UTC`
12})
13</script>
14
15<template>
16  <time :datetime="new Date(at).toISOString()" :title="text && exact">{{ text ?? exact }}</time>
17</template>

mounted is false on the server and while the browser hydrates, and both renders format the same UTC date. onMounted then sets it, exact is worked out again, and Vue redraws the date in local time. Both renders use the same locale too: useTinkerfundLocale, in layers/tinkerfund/app/composables/locale.ts, reads it from the request's Accept-Language header on the server and keeps it in useState. The Journal's page, [space]/frictions.vue in layers/journal/app/pages/t/journal/, does the same with today's date: useState holds the server's date for the first render, and onMounted replaces it with the browser's.

The Journal shows a link to each session's Claude Code page only in a maintainer view, which a ?maintainer query or the browser tab's storage turns on. Its composable useMaintainerView keeps that answer in useState:

layers/journal/app/composables/maintainerView.ts ↗8–14as of
8export function useMaintainerView() {
9  const on = useState<boolean | null>('journal-maintainer-view', () => null)
10  onMounted(() => {
11    if (on.value === null) on.value = resolveMaintainerView(location.search, () => sessionStorage)
12  })
13  return on
14}

The value is null, not yet known, on the server and through hydration, and neither render draws the link. Every session card calls useMaintainerView; they share one value through the key, and the first card mounted reads the address and storage for all of them.

The smoke test, tests/e2e/smoke.spec.ts, loads every 's entry page from a production build in a real browser, and tests/support/e2e.ts fails it on any console error, Vue's mismatch error included.

Why it's built this way

  • Hydrate, don't reload. The owner wants view transitions and in-memory state, such as Tinkerfund's cart, to survive a click, so the browser takes over the server's page and builds later pages itself, rather than loading each from the server. The cost: every component of a server-rendered page runs its setup twice, and both renders must agree. ADR-0028 (docs/adr/0028-navigation-reads-server-payloads.md).
  • The server's value first, the browser's after mount. A cached page's server-computed "today" can be slightly old, and only the browser knows the visitor's time zone. Rendering the server's value and replacing it in onMounted keeps hydration clean. The cost: the server's value shows until the page mounts. ADR-0028 (docs/adr/0028-navigation-reads-server-payloads.md).
  • Request facts travel in useState. Money and dates follow the visitor's locale, which the server reads from a request header. Passing it in useState makes the browser render with the server's locale, not its own. The cost: the page cache must vary by that header (Nitro server engine). layers/tinkerfund/app/composables/locale.ts.
  • Dates spelled out by hand. toLocaleDateString can format one date differently on the server and in the browser, which breaks hydration. Tenants such as the Midden build 16 Jul 2026 from a fixed list of month names instead. The cost: those dates read in English for everyone. layers/midden/app/utils/find.ts.

What to watch for

  • The tests miss a mismatched class or style. A production build reports mismatched text but never a mismatched class, style or attribute. Vue corrects a bound attribute silently, but leaves a mismatched class or style as the server wrote it until that binding changes. The smoke test passes on it. Run pnpm dev and read the browser console: development builds warn about each.
  • useState keys share one namespace. Every layer's useState calls share one set of keys, the way their components and composables share one set of names (Nuxt layers). Terrarium's keys start with the Tenant's name, as in journal-maintainer-view, or two Tenants' state could collide.

Try it in your project

In a Nuxt app, add a page that renders the time:

<!-- app/pages/clock.vue -->
<script setup lang="ts">
const now = new Date().toISOString()
</script>

<template><p :class="now">Rendered at {{ now }}</p></template>

Run nuxt dev, open /clock and the browser console: Vue warns of a text mismatch and a class mismatch, and the paragraph shows the browser's time while its class keeps the server's. Run nuxt build, then nuxt preview: the console shows only Hydration completed but contains mismatches. Then fix it: keep the time in useState('now', () => new Date().toISOString()), and update it in onMounted. Both warnings go.

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/nuxt-vue/hydration