SSR and hydration
See how a page is rendered on the server and hydrated in the browser, why the two renders must match, and which tool keeps browser-only values from breaking it.
What you'll learn
When you finish, you can do these.- 1Explain how a page renders on the server, then hydrates
- 2Choose the right tool for values only the browser knows
- 3Hydrate a review list only when it scrolls into view
- 4Spot and fix a text or class hydration mismatch
The Corner Shop, the small online shop this track builds, has a home page that shows each visitor their local time, lists what they viewed recently and remembers whether they chose dark mode. Nuxt renders a page on a server first (server-side rendering, or SSR), so the HTML arrives with content before any JavaScript runs. The server has no visitor clock and no localStorage, the browser's own storage. Code that reads them can fail on the server, or build one page there and another in the browser, so the page flickers or warns in the console.
The Code tab holds that page as files to copy into a Nuxt project, one component for each part, and by the end you can say why each one is written the way it is. You need one idea from the lesson Layouts and page meta: app/app.vue, the root component, wraps every page, so a theme set there covers the whole site.
Two renders of the same component
On the first visit, every component runs twice, once on the server and once in the browser. On the server, Nuxt runs each component's setup, its <script setup> code, and renders HTML, which travels with a payload: a script holding the data the server used.
The browser shows the HTML at once, loads the JavaScript and runs the same components again. Instead of building new DOM, the browser's tree of elements, Vue walks the HTML on screen and attaches the event listeners, the code behind each @click, to its elements. That step is hydration. Until it finishes, the page is visible but its buttons do nothing.
run setup()no window, no DOMrender HTMLsend HTML + payload
show HTMLvisible, not interactiveload JavaScripthydraterun setup() again, attach listenersinteractive
Hydration happens once, on the first load: after that, a link to another page of the shop renders it in the browser alone.
Only the browser knows the visitor's clock. onMounted() takes a function and runs it only in the browser, after hydration. The shop's local time is its own component, LocalTime.vue in the Code tab, and it sets the time in onMounted():
<script setup lang="ts">
const time = ref<string | null>(null)
onMounted(() => {
time.value = new Date().toLocaleTimeString()
})
</script>
<template>
<p>Your time: {{ time ?? '--:--' }}</p>
</template>
The server renders --:--, the browser's first render agrees, and the real time appears a moment later.
When the two renders disagree
Hydration expects the browser's first render to match the server's HTML exactly. When text differs, Vue warns in development, Hydration text content mismatch on <p>, with a rendered on server and an expected on client line, then replaces the text with the browser's version.
Classes, styles and attributes are only checked. Vue warns, for example Hydration class mismatch on <div>, and adds "Note: this mismatch is check-only". The element keeps the server's class until that value changes again, so a theme class worked out from browser state stays wrong.
Code that touches window, the browser's global object, during setup never reaches hydration. The server has no window, so its render throws window is not defined, and the visitor gets a 500 error page. A check of import.meta.client, which is true only in the browser (the client), skips such a line on the server.
Four tools handle values only the browser knows. Pick one per value:
onMounted()for a value only the browser knows, rendered after hydration, as inLocalTime.vue.<ClientOnly>for a whole component that needs the browser. The server renders a fallback in its place, and the component's code is left out of the server build.useCookie()for a preference the server must know on the first paint, the moment the page appears. The browser sends cookies, small values saved for a site, with every request, so the server can render the right theme.data-allow-mismatch(Vue 3.5) on an element whose content is expected to differ, such as a timestamp, to silence the warning for that element.
On the home page, app/pages/index.vue, the recently viewed list is its own component, RecentlyViewed.vue, which reads localStorage during setup. The page wraps it in <ClientOnly>:
<template>
<main>
<h1>Corner Shop</h1>
<LocalTime />
<ClientOnly fallback="Loading your recently viewed items…">
<RecentlyViewed />
</ClientOnly>
<LazyReviewList hydrate-on-visible />
</main>
</template>
The server sends the fallback text, Loading your recently viewed items…, in the list's place, and the browser renders the list after hydration. The next section explains the last line.
Hydrate less, later
Hydration costs time: each component's JavaScript must load and run before its buttons work. A part far below the fold, the line where the first screen ends, can wait. On the shop's home page that is the review list, ReviewList.vue in the Code tab, with its Helpful button.
In the page, write Lazy before the component's name, so Nuxt loads its code separately, and add an attribute that says when to hydrate. In index.vue, <LazyReviewList hydrate-on-visible /> hydrates the list when it scrolls into view. Its HTML is server-rendered either way, so the reviews are readable at once; only the Helpful button waits.
hydrate-on-idle waits until the browser has nothing else to do, hydrate-on-interaction until the first click or hover, and hydrate-never keeps the component as static HTML.
What goes wrong
A timestamp or a random number printed straight into the template differs between the two renders, as in this version of LocalTime.vue:
<template>
<p>Your time: {{ new Date().toISOString() }}</p>
</template>
The server writes its time, and a moment later the browser computes another. The console shows Hydration text content mismatch on <p> with both values, and Math.random() does the same.
If the value belongs to the browser, set it in onMounted. If the difference is expected and harmless, mark the element:
<template>
<p data-allow-mismatch="text">Your time: {{ new Date().toISOString() }}</p>
</template>
The warning is gone, and the browser's time still replaces the server's.
A theme read from localStorage is right in the browser and wrong on the first paint, as in this version of app.vue:
<script setup lang="ts">
const theme = ref(import.meta.client ? window.localStorage.getItem('theme') ?? 'light' : 'light')
</script>
The import.meta.client check stops the 500 error, but the server cannot see localStorage, so it renders light for everyone. For a visitor who chose dark, the browser computes dark, Vue warns Hydration class mismatch on <div>, and the page stays light while the theme button reads Theme: dark. Store the choice in a cookie instead:
<script setup lang="ts">
const theme = useCookie<'light' | 'dark'>('theme', { default: () => 'light' })
</script>
The server reads dark from the request, so both renders agree from the first paint.
What to remember
- The server renders the page first, and the browser hydrates it: it runs setup again and attaches listeners to the existing HTML.
- Reading
windoworlocalStorageduring setup crashes the server render. UseonMounted()for one value, or<ClientOnly>for a whole component. hydrate-on-visibleand its siblings delay hydration for parts of the page that can wait.- Hydration expects identical output. Vue replaces mismatched text with a warning, but class, style and attribute mismatches stay wrong.
- A preference that must be right on the first paint belongs in a cookie, read with
useCookie().
In your project
Do these in a real project. Each one says how to confirm it worked.- In
LocalTime.vue, replace{{ time ?? '--:--' }}with{{ new Date().toISOString() }}and reload. The console showsHydration text content mismatch on <p>with the server and client values. Adddata-allow-mismatch="text"to the<p>and reload: the warning is gone. - Click the theme button until it says dark, then open View Source: the root
<div>already has thedarkclass in the server HTML, so there is no flash. Swap in thelocalStorageversion from What goes wrong, runlocalStorage.setItem('theme', 'dark')in the console and reload: the console showsHydration class mismatchand the page stays light. - Make the window short enough that the reviews start below the fold, open the Network tab and reload. The request for the
ReviewListcode appears only when you scroll the list into view, and Helpful starts counting after that. Removehydrate-on-visibleand reload: the request now comes with the first load.