Hydration bugs (markup from server does not match client) surface as “random” UI or console warnings. During framework upgrades, update your SSR fixtures first: snapshot critical routes with stable data, then compare pre/post.
1. Node and platform runway
Your hosting (Kubernetes, Vercel, Netlify, DIY VM) and Node LTS line must be compatible with the Vue server renderer you will run in 2026+. Put “upgrade Node in prod” in the same program as the Vue work if needed.
2. Data loading boundaries
Clarify what fetches on server, what fetches in browser, and what must be deferred. Double-fetch and flash-of-wrong-content are the usual regressions. Align with i18n so locale and messages resolve consistently on both sides.
2b. Hydration mismatches: how they happen and how to spot them
A hydration mismatch happens when the markup the server emits is not byte-equivalent (after normalization) to what the client renders on first pass. Vue 3 is stricter and more vocal about this than Vue 2 ever was. The console warning—Hydration node mismatch—is the easy case. The hard cases are silent: event handlers attached to the wrong element, lost focus, or a flash of technically valid but wrong content.
Common causes we see in real Vue 3 migrations:
- Date and locale formatting. Server renders
Intl.DateTimeFormatin UTC; client renders in the user’s timezone. Pin the locale and timezone explicitly on both sides. - Random IDs.
Math.random()insetup()generates different values per render. UseuseId()or a deterministic source. - Conditional rendering on
window.v-if="window.innerWidth > 768"is always false on the server. Defer to client-only blocks. - Cookie-based personalization. Server reads a cookie and renders “Welcome, Alice”; client doesn’t see the cookie until later. Push that read into the SSR data layer.
<ClientOnly>
<ResponsiveChart :width="viewportWidth" />
<template #fallback>
<div class="chart-skeleton" aria-busy="true" />
</template>
</ClientOnly>Always provide a fallback. A bare <ClientOnly> with no skeleton causes a layout shift that tanks your CLS score.
3. Caching and personalization
CDN Cache-Control and edge keys must be revalidated when the shell changes. A Vue 3 migration is a good moment to audit who gets a personalized SSR response where—or you ship subtle privacy bugs.
4. Choosing your render mode
“Do we need SSR?” is the wrong question. The right one is: which render mode does each route need? A Vue 3 migration is the cleanest moment to make that explicit instead of inheriting whatever Nuxt 2 was doing five years ago.
| Mode | Best for | Server cost | Hydration risk |
|---|---|---|---|
| CSR (SPA) | Authed dashboards | Lowest | N/A |
| SSR (per-request) | Personalized landing | Highest | High |
| SSG (build-time) | Marketing, docs | ~0 | Low |
| ISR / on-demand | Catalog pages | Medium | Medium |
| Edge SSR | Geo-personalized | Medium | High |
Most apps end up with two or three modes coexisting: SSG for marketing, SSR for authenticated landing, CSR for the deep app. Document the choice route-by-route. If you’re moving from Nuxt 2, the Nuxt 3 migration guide covers how Nitro maps onto these modes.
5. Server runway: capacity, error budgets, observability
Once you SSR, you’re running a server product. That changes what “done” means. Migration plans that ignore this ship a green dev environment and a flaky prod.
- Capacity model. Estimate concurrent renders, average render time, and memory per request. A Vue 3 SSR worker that holds 200MB at idle and renders for 80ms is a different sizing exercise than the static host you replaced.
- Streaming and timeouts. Vue 3’s
renderToWebStreamis great for TTFB but exposes you to client disconnects mid-stream. Set explicit upstream timeouts. - Memory leaks. Singletons (Pinia, axios instances, i18n messages) attached to the wrong scope leak across requests. Always create per-request app instances.
- Observability. Trace render time, hydration time, and CDN hit ratio separately. A regression in any one of those should page on-call.
- Error budgets. A 0.1% render failure rate that returned a static fallback in CSR-land returns a 500 in SSR-land unless you build the fallback yourself.
Pair the capacity work with the broader migration cost estimate. SSR infra is rarely zero on the bill of materials.
6. Per-request app pattern
The most common SSR bug we debug is shared state across requests: user A sees user B’s name for a few hundred milliseconds. The fix is structural—create a fresh app, router, and Pinia per request, never reuse module-level singletons that hold user data.
// entry-server.ts
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter } from './router'
import App from './App.vue'
export async function render(url: string, manifest: unknown) {
const app = createSSRApp(App)
const router = createRouter()
const pinia = createPinia()
app.use(router).use(pinia)
await router.push(url)
await router.isReady()
const html = await renderToString(app)
return { html, state: pinia.state.value }
}Serialize pinia.state.value into the HTML and rehydrate on the client. Anything you cannot serialize (functions, class instances, dates without ISO strings) is a hydration bug waiting to happen. This pattern matches what Pinia recommends for SSR-safe stores.
7. Common SSR pitfalls during a Vue 2 EOL migration
- Treating SSR as a flag toggle. Nuxt 2’s
mode: 'universal'and Vue 3 SSR are not equivalent. The mental model has to change. - Using browser-only libraries on the server. Anything that touches
document,localStorage, orIntersectionObserverneeds guards or dynamic imports. - CDN caching personalized HTML. A single misconfigured
Varyheader and one user sees another user’s account page. Audit edge cache keys. - Forgetting
<Suspense>. Asyncsetup()functions need it; without, you get hydration warnings on every page. - Skipping the SSR test layer. If your test stack only runs in jsdom, you’re not testing SSR.
8. FAQ
Should we use Nuxt 3 or roll our own SSR on Vue 3?
For most teams, Nuxt 3 is the right answer. Nitro handles deployment targets, the data layer is opinionated, and the community is large. Roll your own only if you have unusual constraints (multi-tenant edge routing, an existing Express monolith). The Nuxt Bridge post covers the in-between path.
Is SSR worth it for an authenticated SaaS?
Usually no. Behind login, SEO is irrelevant and your users tolerate a brief loading state. CSR is cheaper, simpler, and avoids most hydration bugs.
Can we mix SSR and CSR in one Vue 3 app?
Yes—route-level SSR with CSR-only sections via <ClientOnly> is common. Just be explicit and document it.
How does this interact with a strangler migration?
SSR makes the proxy topology of a strangler migration harder—two SSR apps behind one edge requires careful header and cookie handling. Plan it explicitly.
9. SSR migration checklist
- Render mode chosen and documented per route.
- Per-request app, router, and Pinia—no module-level singletons holding user data.
- Hydration fixtures snapshot for the top 20 routes; diffed pre/post Vue 3.
- Node LTS and hosting platform compatible with the chosen renderer.
- CDN cache keys and
Varyheaders audited for personalized content. - Streaming timeouts and error fallbacks in place.
- Observability: render time, hydration time, error rate, cache hit ratio.
- i18n locale resolution identical on server and client.
- SSR test layer that actually exercises Node, not just jsdom.
Treat this list as a gate before the cutover. SSR bugs caught in staging are cheap; the same bugs in production show up as “the site is randomly broken for some users,” which is the worst kind of incident to triage.
Conclusion
Vue 2 EOL pushes you to supported libraries; SSR means you are also operating a server product. Plan capacity, error budgets, and hydration tests alongside SFC rewrites—otherwise the migration is “green in dev” and flaky at scale.
