SSR, hydration and Nuxt

Vue · Expert · 15 min

Server rendering improves first paint and SEO but introduces state-leak and mismatch bugs that need expert understanding.

With server-side rendering, the server runs your components to produce HTML, the browser shows it immediately, and then Vue hydrates it: it creates the same app with createSSRApp, walks the existing DOM instead of creating it, and attaches listeners and reactivity.

On the server you call renderToString(app) (or a streaming variant) from vue/server-renderer. Only setup and server-safe code run; onMounted and other DOM hooks do not, so browser APIs belong there.

Two classes of bugs dominate:

  • Cross-request state pollution: modules are evaluated once per server process, so module-level refs or stores are shared by all users. Create the app, router and Pinia per request.
  • Hydration mismatches: if the client's first render differs from the server HTML (dates, Math.random(), window checks, invalid HTML nesting like a div inside a p), Vue warns and patches. Fix the cause: render such values only after mount, or mark an intentional difference with Vue 3.5's data-allow-mismatch attribute. Use useId() for ids that must match on both sides.

Vue 3.5 adds lazy hydration for async components: defineAsyncComponent({ loader, hydrate: hydrateOnVisible() }) delays hydrating below-the-fold parts (also hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery).

Hand-rolled SSR is rare. Nuxt provides SSR, static generation, file-based routing, data fetching (useFetch, useAsyncData) with server-to-client payload transfer, and server routes. Learning raw SSR mainly helps you debug Nuxt.

Example

// server.js (Node) - minimal SSR with per-request app creation
import { createSSRApp, ref, h } from 'vue'
import { renderToString } from 'vue/server-renderer'

function createApp() {                 // fresh app per request: no shared state
  return createSSRApp({
    setup() {
      const count = ref(0)
      return () => h('button', { onClick: () => count.value++ }, `Count: ${count.value}`)
    },
  })
}

const html = await renderToString(createApp())
console.log(html)                      // <button>Count: 0</button>

// client.js: createApp().mount('#app')   // hydrates existing HTML (createSSRApp)

// Lazy hydration of a below-the-fold component (Vue 3.5+):
// const Comments = defineAsyncComponent({
//   loader: () => import('./Comments.vue'),
//   hydrate: hydrateOnVisible(),
// })

Remember

  • SSR renders HTML on the server; hydration attaches Vue to it with createSSRApp
  • Create app, router and stores per request to avoid leaking state between users
  • Mismatches come from non-deterministic or browser-only render output; fix or mark them
  • Nuxt packages SSR, routing and data fetching for real projects

Watch out for

  • Rendering new Date().toLocaleTimeString() during SSR; the client renders a different time and hydration mismatches.

Check yourself

  1. A store defined as a module-level reactive object is used in an SSR app. What is the risk?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: vuejs.org/guide/scaling-up/ssr.html