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
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:
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:
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
onMountedkeeps 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 inuseStatemakes 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.
toLocaleDateStringcan format one date differently on the server and in the browser, which breaks hydration. Tenants such as the Midden build16 Jul 2026from 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
classorstyle. A production build reports mismatched text but never a mismatched class, style or attribute. Vue corrects a bound attribute silently, but leaves a mismatchedclassorstyleas the server wrote it until that binding changes. The smoke test passes on it. Runpnpm devand read the browser console: development builds warn about each. useStatekeys share one namespace. Every layer'suseStatecalls 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 injournal-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
- LessonVue components
- LessonData fetching
- LessonNitro server engine
- LessonTesting
- LessonNuxt layers
Walkthroughs through this Lesson
- WalkthroughFrom file to browser
Last updated · Latest source on GitHub ↗