20 — SSR & Nuxt Basics

SSR Lifecycle — Server vs Client

ssr-lifecycle.ts
// ── Server-side rendering lifecycle: ────────────────────
// 1. createApp() — app instance created (per-request in production)
// 2. setup() runs for all components — refs, computed, watchers registered
// 3. onServerPrefetch callbacks fire — async data fetching
// 4. Server waits for all onServerPrefetch promises to resolve
// 5. renderToString() — renders the component tree to HTML string
// 6. HTML + serialized state sent to client

// ── Client hydration lifecycle: ──────────────────────────
// 1. Browser receives HTML + JS bundle
// 2. HTML is displayed immediately (user sees content before JS loads)
// 3. createApp() — same component tree created on client
// 4. onServerPrefetch SKIPPED — server already fetched the data
// 5. Hydration: Vue "attaches" to existing DOM (no re-render, just event listeners)
// 6. If DOM matches expected VNodes: seamless. If not: hydration mismatch warning.

// ── Key difference: hydration is NOT a re-render ─────────
// Vue assumes the server HTML is correct and just adds interactivity.
// If the client and server produce different HTML, Vue discards the server
// HTML and re-renders from scratch (expensive, visible flash).

onServerPrefetch — Data Preloading on Server

SSRDataFetch.ts
import { ref, onServerPrefetch, onMounted } from 'vue'

const data = ref(null)

// ── onServerPrefetch: runs ONLY on the server during SSR ──
// The callback returns a promise — server waits for resolution before rendering.
// The fetched data is in the component's reactive state when HTML is generated.
onServerPrefetch(async () => {
  const res = await fetch('https://api.example.com/data')
  data.value = await res.json()
})

// ── Client-side: fetch if data wasn't provided by SSR (no SSR data) ──
// During hydration, onServerPrefetch is skipped — data stays null if not injected.
// This onMounted acts as a fallback for client-side navigation (SPA mode).
onMounted(async () => {
  if (!data.value) {
    const res = await fetch('/api/data')
    data.value = await res.json()
  }
})

State Serialization — Server to Client

state-serialization.ts
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import { createPinia } from 'pinia'
import App from './App.vue'

// ── Server: render + serialize state ─────────────────────
export async function serverRender() {
  const pinia = createPinia()  // FRESH per request (no cross-request leaks)
  const app = createSSRApp(App)
  app.use(pinia)

  // ── Pre-populate Pinia stores (equivalent to onServerPrefetch) ──
  const userStore = useUserStore(pinia)
  await userStore.fetchUser()

  // ── Render to HTML string ──
  const html = await renderToString(app)

  // ── Serialize Pinia state for client hydration ──
  const state = JSON.stringify(pinia.state.value)

  // ── Inject into HTML: client reads this on load ──
  return `
    <div id="app">${html}</div>
    <script>window.__PINIA_STATE__ = ${state}</script>
  `
}

// ── Client: hydrate with server state ───────────────────
export function clientHydrate() {
  const pinia = createPinia()

  // ── Restore server state before mounting ──
  if (window.__PINIA_STATE__) {
    pinia.state.value = window.__PINIA_STATE__  // replace, not merge
  }

  const app = createSSRApp(App)  // createSSRApp for hydration (not createApp)
  app.use(pinia)
  app.mount('#app')  // hydrates existing DOM, doesn't re-render
}

Hydration Mismatches — Causes and Fixes

hydration-mismatches.ts
import { ref, computed, onMounted } from 'vue'

// ── 1. Date/time-based rendering (server time ≠ client time) ──
const now = ref(new Date())
// Server renders 14:00, client renders 14:01 → mismatch
// Fix: render relative time on client only, or use a fixed timestamp from server

const formattedTime = computed(() => {
  // ❌ Bad: server and client compute different values
  return new Date().toLocaleTimeString()  // different on server vs client

  // ✅ Good: use a fixed timestamp (from server data)
  return new Date(serverTimestamp.value).toLocaleTimeString()
})

// ── 2. Math.random() in render ──────────────────────────
const randomId = Math.random()  // different on server and client
// Fix: generate random IDs in onMounted (client-only) or use UUID from server

// ── 3. window/document in template or setup ─────────────
// Server: no window → undefined or error
const width = ref(0)
// ❌ Bad: window in setup — crashes on server
// const width = ref(window.innerWidth)
// ✅ Good: read in onMounted (client-only)
onMounted(() => { width.value = window.innerWidth })

// ── 4. v-if based on client-only state ───────────────────
const isClient = ref(false)
onMounted(() => { isClient.value = true })  // true only on client
// Server renders v-if="false" (hidden), client renders v-if="true" (shown) → mismatch
// Fix: use ClientOnly wrapper (Nuxt) or :disabled on Teleport

// ── 5. CSS order differences (server vs client bundle) ──
// Different CSS load order can cause different computed styles.
// Not a Vue issue per se, but manifests as hydration warning.

Nuxt — Universal Rendering Architecture

nuxt-patterns.ts
// ── Nuxt 3: file-based routing, auto-imports, universal rendering ──

// ── 1. useFetch: Nuxt's built-in data fetching (SSR + client) ──
import { useFetch, useAsyncData } from '#imports'  // auto-imported

// useFetch: handles SSR pre-fetch, client hydration, and client-side refetch
const { data: users, pending, error, refresh } = await useFetch('/api/users')
// Server: fetches during SSR, serializes result in payload
// Client: hydrates from payload (no re-fetch), refetches on client navigation

// ── 2. useAsyncData: custom fetcher with SSR ──
const { data } = await useAsyncData('users', async () => {
  // Custom fetcher — Nuxt handles SSR serialization + hydration
  const res = await $fetch('/api/users')
  return res
})

// ── 3. Server routes (Nuxt server API) ──
// server/api/users.get.ts:
// export default defineEventHandler(async () => {
//   return await db.query('SELECT * FROM users')
// })

// ── 4. ClientOnly: render children only on client (avoid mismatch) ──
// <ClientOnly>
//   <MapComponent />  <!-- rendered only after hydration -->
//   <template #fallback>
//     <div>Loading map…</div>  <!-- server + initial client -->
//   </template>
// </ClientOnly>

// ── 5. SSR-safe window access: import.meta.client ──
if (import.meta.client) {
  // Client-only code — safely skipped on server
  window.addEventListener('resize', handler)
}

SSR-Safe Composables

ssr-safe-composables.ts
import { ref, onMounted, onScopeDispose, watch, type Ref } from 'vue'

// ── SSR-safe composable: handles server (no DOM) and client ──
export function useWindowWidth(): Ref<number> {
  const width = ref(0)

  // ── onMounted: client-only — server never runs it ──
  onMounted(() => {
    width.value = window.innerWidth
    const handler = () => { width.value = window.innerWidth }
    window.addEventListener('resize', handler)
    onScopeDispose(() => window.removeEventListener('resize', handler))
  })

  // ── Server: width stays 0 (no window) ──
  // Client: width updates on mount (hydration-compatible — 0 matches server)
  return width
}

// ── SSR-safe localStorage composable ──
export function useLocalStorage(key: string, initial: string): Ref<string> {
  const value = ref(initial)

  // ── onMounted: client-only — localStorage doesn't exist on server ──
  onMounted(() => {
    const stored = localStorage.getItem(key)
    if (stored !== null) value.value = stored

    // Watch: save to localStorage on change
    watch(value, (newVal) => {
      localStorage.setItem(key, newVal)
    })
  })

  return value
}

💡 Tips & Tricks

tips.ts
// ── 1. createSSRApp vs createApp ──
// createSSRApp: hydrates existing DOM (SSR) — doesn't re-render on mount.
// createApp: replaces DOM (SPA) — creates new DOM from scratch.
// Using createApp for SSR: full re-render, hydration warning, flash of content.

// ── 2. Hydration: Vue assumes server HTML is correct ──
// If mismatch: Vue discards server HTML, re-renders. Visible flash + perf cost.
// Debug: run in dev mode — Vue logs hydration mismatch warnings with details.

// ── 3. Pinia: createPinia per request on server ──
// Module-scoped Pinia leaks state between requests (security bug).
// Nuxt handles this automatically; custom SSR must create per-request.

// ── 4. ClientOnly: skip SSR for client-only components ──
// Maps, charts, webcams — anything requiring window/canvas.
// Server renders fallback, client renders real content after hydration.

// ── 5. import.meta.client / import.meta.server (Nuxt/Vite) ──
// Tree-shakeable: client-only code removed from server bundle and vice versa.
// Better than typeof window checks (runtime check, not build-time elimination).

⚠️ Edge Cases & Gotchas

edge-cases.ts
// ── 1. Module-scoped state leaks across SSR requests ──
// const shared = ref(0) at module scope → Request A's data visible to Request B.
// Fix: create state per-request (Pinia, or factory function in app creation).

// ── 2. onServerPrefetch errors crash SSR if not caught ──
// If onServerPrefetch throws, the entire server render fails (500 error).
// Wrap in try/catch, or use Nuxt's useFetch (auto error handling).

// ── 3. Hydration mismatch on Date formatting ──
// Server: UTC timezone. Client: user's timezone.
// Same timestamp → different formatted strings → mismatch.
// Fix: format on client only (ClientOnly) or use UTC consistently.

// ── 4. Teleport to="body" breaks on server (no document) ──
// Use :disabled="typeof document === 'undefined'" or Nuxt's <Teleport> wrapper.
// Server renders in-place, client teleports on hydration.

// ── 5. Third-party libraries requiring window crash SSR ──
// Wrap in <ClientOnly> or import dynamically in onMounted.
// Or: use Nuxt plugin with mode: 'client' (only registered on client).

// ── 6. Async setup + SSR: requires Suspense ──
// Top-level await in setup makes the component async.
// On server: renderToString waits for it. On client: needs <Suspense>.

🧠 Spot the Bug

An SSR app shows a hydration mismatch warning for a clock component.

ClockBug.vue
<script setup>
import { ref, onMounted } from 'vue'

const time = ref(new Date().toLocaleTimeString())  // ← computed at setup time
</script>

<template>
  <div>Current time: {{ time }}</div>
</template>
Answer

new Date().toLocaleTimeString() runs during setup() — on the server, it captures the server's time at render time. On the client, setup() runs during hydration and captures a different time (milliseconds or seconds later, possibly a different timezone). The server HTML says "14:00:00.000" but the client computes "14:00:00.250" — mismatch.

Fix — render the time only on the client (after hydration) or use a fixed timestamp from the server:

ClockFixed.vue
<script setup>
import { ref, onMounted } from 'vue'

const time = ref('')

onMounted(() => {
  // Client-only: updates after hydration — no mismatch
  time.value = new Date().toLocaleTimeString()
})
</script>

<template>
  <div>Current time: {{ time || 'Loading…' }}</div>
</template>

The lesson: any value that depends on "when" it's computed (Date, Math.random, performance.now) will differ between server render and client hydration. Compute it in onMounted (client-only) or use a fixed value from server-side data.