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(),windowchecks, invalid HTML nesting like adivinside ap), Vue warns and patches. Fix the cause: render such values only after mount, or mark an intentional difference with Vue 3.5'sdata-allow-mismatchattribute. UseuseId()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.