Skip to content
intermediate Phase 14 · Performance Optimization and Server-Side Rendering

Server-Side Rendering and Hydration

Understand how HTML from the server becomes an interactive client app.

45m
0 problems
Topic Progress 0%

From renderToString to first click: what the server delivers and what hydration takes over

A single-page app ships an empty <div id="app"> and a JavaScript bundle; first paint waits for the bundle to download, parse, and run, and a crawler that does not execute scripts sees nothing at all. Server-side rendering flips the order: the server executes your Vue app and sends complete HTML, so the browser paints immediately and search engines read real content. Something then has to make that static HTML interactive — that something is hydration.

The server side is a Vue app like any other, mounted through renderToString instead of into a DOM node:

// server.js (runs under Node — no window exists here)
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import App from './src/App.vue'

export async function render(url) {
  const app = createSSRApp(App)
  const html = await renderToString(app)

  return `<!doctype html>
<html>
  <head><title>Orders</title></head>
  <body>
    <div id="app">${html}</div>
    <script type="module" src="/client.js"></script>
  </body>
</html>`
}

The client entry looks almost identical — with one swap that decides whether anything works:

import { createSSRApp } from 'vue'
import App from './src/App.vue'

createSSRApp(App).mount('#app')

createSSRApp, not createApp. With createApp the client treats the server HTML as unknown, throws it away, and renders fresh — users see the content flash and vanish, every node is recreated, and you paid for SSR for nothing. createSSRApp puts the renderer in hydration mode: it walks the vnode tree in parallel with the existing DOM, reuses matching nodes instead of creating them, and attaches event listeners and reactive effects to what is already on screen. When the trees match, hydration produces almost no DOM writes; when they do not, you get a mismatch warning — a subject with its own debugging chapter below.

Reactivity and data cross the boundary differently. Component state must be reconstructed identically on the client, which means any data the server fetched gets serialized into the page:

const state = { orders: await fetchOrders() }
// serialize safely: escape "<" so "</script>" in data cannot break out
const payload = JSON.stringify(state).replace(/</g, '\\u003c')

app.provide('initialState', state)          // server side
// client: JSON.parse(document.getElementById('payload').textContent)

The escaping is not optional — embedding raw JSON in a <script> tag is a well-known XSS vector. Libraries such as devalue handle richer types (Dates, Maps, undefined) and the escaping for you; Nuxt does this automatically under the name payload.

The trade-offs are worth stating plainly before you commit to SSR:

Concern Server rendering buys Server rendering costs
First paint HTML paints with zero JS execution server compute per request (or build step)
SEO / social previews crawlers read full content markup must stay deterministic
Bundle — the client still downloads and hydrates the same components
State payload transfer serialization discipline

That third row is the one people miss: SSR improves perceived start, not download size — which is why the async-component and chunking work from Async Components and Code Splitting still applies to an SSR app. The client bundle has to arrive before any button responds. Streaming pushes the boundary further: renderToNodeStream sends HTML chunks as the render produces them, so first byte does not wait for the slowest widget on the page — Nuxt streams automatically when a page awaits inside a Suspense boundary.

Hydration completes the contract: the server rendered the HTML, the client re-creates the same tree, and Vue binds the two together. If both sides do not agree character for character, the warnings in the next chapter tell you exactly where they diverged.

Hydration mismatch: read the warning, then hunt the nondeterministic input

The console shows [Vue warn]: Hydration node mismatch and your page's buttons have stopped responding. Vue has detected that the DOM the server sent differs from the tree the client tried to bind to, and in development it tells you precisely which node diverged. Reading that message is a mechanical skill.

The warnings you will actually see, all in development builds:

Console message What differs
Hydration node mismatch element type or structure — a <div> where the client expected <span>
Hydration text mismatch same node, different text content
Hydration attribute mismatch an attribute value differs (class, src, style)
Hydration children mismatch on <p> child count differs — usually invalid HTML the parser restructured
Hydration completed but contains mismatches. summary printed after hydration; Vue patched the differences

Each detail warning pairs - rendered on server: with - expected on client: lines naming the offending value — that pair is your fastest way from console to source line. In production builds the checks are stripped for speed; the failure mode there is blunt: Hydration failed because the initial UI does not match what was rendered on the server. and the subtree is re-rendered from scratch, which users experience as a flash of content swapping.

Almost every mismatch traces back to one of five causes:

  1. Non-determinism in the render path. Math.random(), crypto.randomUUID(), Date.now(), or new Date().toLocaleString() — the server produces 10:32 in UTC, the client produces 12:32 in Berlin, and you have a Hydration text mismatch.
  2. Branching on browser state. typeof window !== 'undefined' ? desktopNav : mobileNav, a read of localStorage for the dark theme, or window.innerWidth in a v-if — the server takes the false branch, the client the true one.
  3. Invalid HTML nesting. A <div> inside a <p> is silently reparented by the HTML parser, so the DOM the browser builds no longer matches the vnode tree the server serialized. The fix belongs in the template: valid nesting, not a div soup workaround.
  4. Transformations between server and client. An edge CDN minifying HTML, a browser extension rewriting attributes, or CSS-in-JS injecting different style values — the markup was fine until something rewrote it in transit. Cloudflare's automatic HTML minification, for instance, is a documented hydration-breaker; disable it for SSR pages.
  5. Data fetched twice with different answers. The server reads the database, the client fires its own request, and a row changed in between — same code, different inputs, mismatched output.

The debugging routine is the same regardless of cause: reproduce on a development build (production strips the warnings), read the - rendered on server: / - expected on client: pair to identify the node, open that component, and grep its template's dependencies for random, Date, window, localStorage. Then make the first client render deterministic and push the nondeterminism past hydration:

<script setup>
const now = ref('')                       // server and client both start with ''
onMounted(() => { now.value = new Date().toLocaleTimeString() })  // fills after hydration
</script>

<template>
  <p>Updated {{ now || 'just now' }}</p>
</template>

For unique element IDs, Vue 3.5's useId() generates a server/client-matched value — far better than a counter or random, both of which mismatch on purpose. For widgets that must not render on the server at all (window-dependent charts, matchMedia layouts), wrap them in <ClientOnly> so the server renders a placeholder both sides agree on.

The invariant to memorize: the initial client render must be a pure function of the same inputs the server saw. Anything read from the browser, the clock, or a random source has to wait until after onMounted. The next chapter maps which APIs are even legal during server rendering.

Decide what is allowed to run on the server

During SSR your component's setup() runs in Node, where window, document, localStorage, and navigator do not exist — and then the very same code runs again in the browser, where they do. Server rendering succeeds only when you know, line by line, which side you are on and which APIs are legal there.

The registry of common APIs is short enough to keep in your head:

API / action Legal on the server? Where it belongs
ref, computed, lifecycle, templates yes anywhere
App data fetching (fetch to your API) yes setup() with awaited data
window / document / navigator no onMounted() or <ClientOnly>
localStorage / cookies read in JS no cookies sent to the server, read via request headers
setTimeout in setup() runs, but no teardown guard with instance hooks
DOM measurements (getBoundingClientRect) no onMounted()
Math.random() / Date in output runs — but mismatches render after hydration

The subtle trap is not the immediate crash — window is not defined during SSR is loud and easy to fix. The subtle trap is code that runs on both sides and produces different values: it renders without error, then mismatches during hydration. setup() executes twice by design — once to produce HTML, once to rebuild state on the client — so any branchy browser logic in it must move out of the first render, as the previous chapter's mismatch examples showed.

The same double execution applies to module scope. Code at the top level of a module runs when the server evaluates the bundle and again in the browser:

// composables/useTheme.js
let cached = null

export function useTheme() {
  if (typeof window !== 'undefined') {   // client only — server keeps cached = null
    cached ??= localStorage.getItem('theme')
  }
  return { theme: cached }
}

Guarding with typeof window !== 'undefined' keeps it from crashing, but note the asymmetry it creates: the server's first render has no theme, the client's does — a mismatch waiting to happen. The robust pattern is to pass the value into SSR instead of reading it during SSR: read the cookie on the server (it arrives in request headers), render with it, and the client reconstructs the identical value from the same cookie.

That raises the architectural requirement behind all of this: the SSR app instance must be created per request. A module-level const app = createSSRApp(App) shared across requests leaks one visitor's state into the next — user A's cart rendered into user B's HTML, a race that only appears under concurrent load. The factory shape is mandatory:

export function createApp(req) {
  const app = createSSRApp(App)
  app.provide('request', req)        // per-request context: headers, cookies, user
  return app
}

Everything request-scoped — the Pinia store created inside createApp, injected headers, error state — lives and dies with that one render.

Two Vue features need explicit server handling. Teleport to a non-body target has no target element during renderToString, so it requires the force prop (or to="body", which the injected page always has). And global side effects that normally happen during setup() — injecting meta tags, appending stylesheets — must be routed through the SSR context so the server can place them into the HTML string instead of mutating a nonexistent DOM; Nuxt's useHead does exactly this bookkeeping for you.

You can lock all of it down with a cheap test: run renderToString in Vitest and assert the HTML is identical across two invocations with frozen clock and seeded randomness:

test('server output is deterministic', async () => {
  const a = await renderToString(createApp({ url: '/' }))
  const b = await renderToString(createApp({ url: '/' }))
  expect(a).toBe(b)
})

Two identical strings do not prove hydration succeeds, but any non-determinism fails immediately — the fastest guard against the browser-only API creeping back into the render path. Hydration also scales with how much you render: a table with 10,000 rows bound in one pass is exactly the pressure the roadmap's next topic releases through Virtual Scrolling for Large Lists, while Nuxt for Full-Stack Vue Apps later automates this entire server pass per request.

Quiz

1. The client boots a plain createApp() over HTML produced by renderToString. What happens?

Question 1 options

2. Which template most reliably produces a Hydration text mismatch?

Question 2 options

3. The console ends with 'Hydration completed but contains mismatches.' What did Vue do?

Question 3 options

4. Which API may run during server-side rendering?

Question 4 options

Flashcards

Question

What is hydration, and which API enables it on the client?

Answer

Hydration binds Vue's reactivity and event listeners onto the HTML the server already rendered, reusing matching nodes instead of recreating them. It only happens with createSSRApp().mount(); createApp() throws the server HTML away and re-renders.

Question

Name Vue's hydration warnings and what each one means.

Answer

Hydration node mismatch (element structure differs), text mismatch (same node, different text), attribute mismatch (an attribute differs), children mismatch (child count differs, often invalid HTML), and the summary 'Hydration completed but contains mismatches.' Each dev warning pairs - rendered on server: with - expected on client:.

Question

What are the five classic sources of a hydration mismatch?

Answer

Clock/random/locale output (Date.now, toLocaleString, Math.random), branches on window/localStorage, invalid HTML nesting the parser reparents, transformations between server and client (CDN HTML minification), and data fetched twice with different results.

Question

What invariant keeps the first client render deterministic?

Answer

The initial client render must be a pure function of exactly the inputs the server saw — any browser-only value (time, random, storage) is filled after onMounted or hidden behind ClientOnly, and unique IDs come from useId() rather than a counter.

Question

Why must the SSR app instance be created per request, and how is state shared with the client?

Answer

A shared module-level app leaks one visitor's state into another's HTML under concurrency — so createSSRApp runs inside a factory per request. Server-fetched state is serialized into the page (JSON with '<' escaped, or devalue) and the client parses the same payload, avoiding a second fetch and a data mismatch.

Revision Notes

Key Takeaways

  • 1. SSR sends complete HTML for instant first paint and crawlable content; hydration attaches listeners and reactivity without rebuilding nodes — but only via createSSRApp
  • 2. Serialization is part of the contract: escape the payload (or use devalue) and reconstruct identical state so the client skips a second fetch
  • 3. Mismatch warnings name the node and show server versus client values — read that pair, then grep the component for Date, random, window, and localStorage
  • 4. setup() runs twice by design, so the initial client render must mirror the server's inputs exactly; browser-only values wait until onMounted or ClientOnly
  • 5. The app instance is per request — a module-level singleton leaks user state across visitors under concurrent load

Interview Tips

  • • "Explain hydration in your own words" — Vue walks the vnode tree against the existing DOM, reuses matching nodes, and attaches events/reactivity; mention createSSRApp versus createApp as the switch
  • • "A page shows 'Hydration node mismatch' — how do you debug?" — development build, read the rendered-versus-expected pair, identify the node, then hunt clock/random/browser branches and invalid nesting in that component's render path
  • • "Is SSR free performance?" — no: first paint improves, but the client still downloads and hydrates the bundle, plus server compute per request; chunking still matters
  • • "How do you pass server data to the client safely?" — serialize into the page with '<' escaped to prevent script-breakout (or devalue for types), provide it server-side, parse on the client before first render

Cheat Sheet

Server-Side Rendering and Hydration

Server → client handoff:

// server: produce full HTML
const app = createSSRApp(App)
const html = await renderToString(app)

// client: hydrate, never re-render
createSSRApp(App).mount('#app')

State crosses via a serialized payload — JSON.stringify(state).replace(/</g, '\\u003c') (or devalue) — and both sides reconstruct identical state.

Mismatch messages:

Warning Differs
Hydration node mismatch element structure
Hydration text mismatch text content
Hydration attribute mismatch an attribute
Hydration children mismatch child count (often invalid nesting)
Hydration completed but contains mismatches. dev summary after patching

Cause → fix:
clock/random/locale → compute after onMounted; window/localStorage branch → render a shared placeholder first; invalid nesting → fix the template; CDN HTML minification → disable it for SSR pages; double fetch → one payload shared by both renders.

API rule: request headers/cookies and app fetches are server-safe; window, document, localStorage, DOM measurement are client-only. Create the app per request (app.provide('request', req)), never at module scope.

Continue Learning