SPA, SSR and SSG

What the browser receives from a SPA, from server rendering and from prerendering, and how to choose per page.

Syntaxconst html = await renderToString(createSSRApp(App))

A Vue application has to turn components into HTML somewhere. Where that happens is the rendering mode, and it decides what arrives in the browser on the first request.

The three modes

In a SPA (single-page application) the host sends a near-empty HTML file and a script bundle. The browser runs the bundle and builds the page. In SSR (server-side rendering) a running server executes the same components for each request and sends finished HTML. In SSG (static site generation, also called prerendering) that HTML is produced once during the build and saved as files.

SSR and SSG both end in hydration: the browser loads the same components over the delivered markup and attaches listeners to the existing DOM instead of rebuilding it. Vue only hydrates when the app is created with createSSRApp. createApp replaces the server markup and renders from scratch.

What each costs

ModeHost needsFirst paintFreshness
SPAStatic filesAfter the bundle runsAlways current
SSRA Node process you keep aliveFirst responsePer request
SSGStatic files or a CDNFirst responseAs of the last build

Choosing

Decide by what the page is for. A docs page has no per-user content, so prerender it. A dashboard behind a login is never indexed and reads private data, so a SPA is enough. A catalogue that changes and must be indexed wants SSR, usually with a cache in front.

The cost people underestimate is that SSR and SSG double the environments your code runs in. Anything that differs between server and browser, such as window or the local clock, now has two answers.

Example

Example · vue
<!-- src/components/PublishedAt.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

const props = defineProps<{ publishedAt: string }>()

const localTime = ref<string | null>(null)
const isoDate = computed(() => props.publishedAt.slice(0, 10))

onMounted(() => {
  localTime.value = new Date(props.publishedAt).toLocaleTimeString()
})
</script>

<template>
  <p>
    <time :datetime="props.publishedAt">{{ isoDate }}</time>
    <span v-if="localTime"> at {{ localTime }} your time</span>
  </p>
</template>

<style scoped>
time {
  font-variant-numeric: tabular-nums;
}
</style>

When to use it

  • Deciding whether a new marketing site needs a server at all, or just prerendered files on a CDN.
  • Explaining to a team why their SPA's product pages show up blank in a link preview.
  • Picking a rendering mode per section when one app holds both public pages and a private dashboard.

More examples

The server entry

This is all server rendering is: build an app, ask the renderer for a string, drop that string into an HTML template. `renderToString` comes from `vue/server-renderer` and returns a promise. A new app is created on every call, which matters because a single app reused across requests would carry one user's state into the next request.

Example · ts
// src/entry-server.ts
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import App from './App.vue'

export async function render(): Promise<string> {
  const app = createSSRApp(App)
  return await renderToString(app)
}

The client entry that hydrates

The browser entry mounts onto the container the server already filled. `createSSRApp` tells Vue that markup is expected inside `#app` and to adopt it. Swapping in `createApp` here is the single most common SSR setup bug: the page still works, but the server HTML is thrown away on mount, so you pay for rendering twice and lose the benefit.

Example · ts
// src/entry-client.ts
import { createSSRApp } from 'vue'
import App from './App.vue'

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

Two builds, one codebase

Vite 8 produces the browser bundle and the server bundle separately; `--ssr` takes the server entry as its argument and targets Node rather than the browser. Running only the first command gives you a SPA. The Vite guide points most teams at a framework such as Nuxt instead of wiring these two builds and a Node server by hand.

Example · bash
# package.json scripts, run in this order
vite build --outDir dist/client
vite build --outDir dist/server --ssr src/entry-server.ts

Discussion

  • Be the first to comment on this lesson.