# Server and Static Rendering

Course: [Vue.js](https://www.devtutorials.ai/learn/vue) · Section: Rendering Modes
Source: https://www.devtutorials.ai/learn/vue/rendering-modes/server-and-static-rendering

> Server-side rendering with renderToString(), the request cycle, a fresh app per request and cross-request state pollution, hydration with createSSRApp(), mismatches and data-allow-mismatch, lazy hydration, onServerPrefetch and state transfer, browser-only code, static site generation, Vite SSR entry files, head tags with useSSRContext, streaming SSR, hybrid rendering with route rules, SWR, ISR and edge, and a mode comparison.

## HTML First, Then JavaScript

- In **How Vue Renders**, the browser built the whole page from JavaScript.
- **Server-side rendering (SSR)** = the server runs Vue and sends finished HTML. The page shows at once.
- Then the browser loads JavaScript and makes the page interactive. This is **hydration**.
- **Static site generation (SSG)** = the same HTML is made once at build time, then served as files.

**Simple lesson:** SSR and SSG send real HTML first. Hydration makes it interactive.

### A Real-World Example

- Meera wants her menu page to show up in search results and in WhatsApp link previews.
- Those tools read the HTML, and many do not run JavaScript.
- With SSR, the HTML already contains the dish names.

const plates = ref<number>(1);
const dishes: string[] = ["Masala Dosa", "Idli", "Vada"];
</script>

<template>
    <h2>Meera's Kitchen</h2>
    <ul>
      <li v-for="d in dishes" :key="d">{{ d }}</li>
    </ul>
    <button @click="plates++">Plates: {{ plates }}</button>
</template>
`,
    "main.ts": `import { createSSRApp } from "vue";

// 1. "Server": render the app to an HTML string.
renderToString(createSSRApp(App)).then((html) => {
    console.log("HTML sent by the server:", html);

    // 2. The browser receives that HTML and shows it at once.
    document.getElementById("app")!.innerHTML = html;

    // 3. Hydration: Vue takes over the existing HTML and adds the listeners.
    createSSRApp(App).mount("#app");
    console.log("Hydrated. The button now works.");
});
`,
  }}
/>

- **What happened:** the Console shows the HTML string, with every dish in it.
- `createSSRApp().mount()` reused that HTML instead of building new elements. Then the button worked.

In a real app, `renderToString` runs on a Node.js server, and the HTML travels over the network. In this lab, both halves run in the preview, one after the other, so you can see each step.

### What You Will Learn

1. The SSR request cycle.
2. Why each request needs a fresh app.
3. What hydration is, and what a mismatch is.
4. How to fix or allow a mismatch.
5. Lazy hydration in Vue 3.5.
6. Loading data on the server, and sending it to the browser.
7. Browser-only code and the SSR lifecycle.
8. Static site generation and rebuilds.
9. How a Vite SSR project is wired, and head tags for SEO.
10. Streaming SSR.
11. Hybrid rendering with SWR, ISR and edge, and how to choose a mode.

## The SSR Request Cycle

<figure className="vue-fig ngc-fig">
<svg viewBox="0 0 640 190" role="img" aria-labelledby="fig-vue-ssr" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-vue-ssr">Animation: a browser requests /menu, the server makes a new app, loads data, renders HTML and sends it. The browser shows the HTML, downloads JavaScript and hydrates, after which the page is interactive</title>
  <rect x="15" y="30" width="170" height="130" rx="12" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)", strokeWidth: 2 }} />
  <text x="100" y="22" textAnchor="middle" style={{ fill: "var(--ngc-blue)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>Browser</text>
  <rect x="455" y="30" width="170" height="130" rx="12" style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)", strokeWidth: 2 }} />
  <text x="540" y="22" textAnchor="middle" style={{ fill: "var(--ngc-green)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>Server (Node.js)</text>
  <g style={{ fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700 }}>
    <text x="540" y="62" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-green)" }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.15;0.17;0.9;1" dur="8s" repeatCount="indefinite" />1. createSSRApp()</text>
    <text x="540" y="86" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-green)" }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.22;0.24;0.9;1" dur="8s" repeatCount="indefinite" />2. load data</text>
    <text x="540" y="110" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-green)" }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.29;0.31;0.9;1" dur="8s" repeatCount="indefinite" />3. renderToString()</text>
    <text x="100" y="62" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-blue)" }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.5;0.52;0.9;1" dur="8s" repeatCount="indefinite" />4. show HTML now</text>
    <text x="100" y="86" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-blue)" }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.6;0.62;0.9;1" dur="8s" repeatCount="indefinite" />5. load JS</text>
    <text x="100" y="110" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-blue)" }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.7;0.72;0.9;1" dur="8s" repeatCount="indefinite" />6. hydrate</text>
    <text x="100" y="140" textAnchor="middle" opacity="0" style={{ fill: "var(--ngc-orange)" }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.8;0.82;0.95;1" dur="8s" repeatCount="indefinite" />interactive ✓</text>
  </g>
  <path d="M185 60 H453" style={{ stroke: "var(--ngc-slate)", strokeWidth: 2 }} />
  <path d="M455 130 H187" style={{ stroke: "var(--ngc-slate)", strokeWidth: 2 }} />
  <text x="320" y="52" textAnchor="middle" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--mono)", fontSize: 11 }}>GET /menu</text>
  <text x="320" y="148" textAnchor="middle" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--mono)", fontSize: 11 }}>{"<html>… dishes …</html>"}</text>
  <circle r="7" cy="60" style={{ fill: "var(--ngc-blue)" }}><animate attributeName="cx" values="185;453;453" keyTimes="0;0.13;1" dur="8s" repeatCount="indefinite" /><animate attributeName="opacity" values="1;1;0;0" keyTimes="0;0.13;0.14;1" dur="8s" repeatCount="indefinite" /></circle>
  <circle r="7" cy="130" opacity="0" style={{ fill: "var(--ngc-green)" }}><animate attributeName="cx" values="455;455;187;187" keyTimes="0;0.35;0.48;1" dur="8s" repeatCount="indefinite" /><animate attributeName="opacity" values="0;0;1;1;0;0" keyTimes="0;0.35;0.36;0.48;0.49;1" dur="8s" repeatCount="indefinite" /></circle>
</svg>
<figcaption>The server builds a fresh app per request and sends HTML. The browser shows it, then hydrates.</figcaption>
</figure>

### A Fresh App per Request

- A server handles many visitors at once, in the same process.
- State at the **top level of a module** is shared by every request.
- If one visitor's data lands there, the next visitor can see it. This is **cross-request state pollution**.
- Fix: export a function that creates a new app (and a new store and router) for each request.

const props = defineProps({ add: String });
if (props.add) cart.value.push(props.add);
</script>

<template>
    <p>Cart: {{ cart.join(", ") || "empty" }}</p>
</template>
`,
    "main.ts": `import { createSSRApp, h } from "vue";

function createApp(add?: string) {
    return createSSRApp({ render: () => h(Cart, { add }) });
}

async function server(): Promise<void> {
    const a = await renderToString(createApp("Dosa"));
    const b = await renderToString(createApp());
    document.getElementById("app")!.innerHTML = \`<p>Request A (Asha): \${a}</p><p>Request B (Ravi): \${b}</p>\`;
}

server();
`,
  }}
/>

- **What happened:** Ravi's page shows Asha's dosa. The cart lived in a shared module.
- In a real SSR app, keep per-user state in components, in `provide`, or in a Pinia store created per request.

Pinia, Vue Router and your own stores must all be created inside the per-request factory: `const pinia = createPinia()` inside `createApp()`, not at the top of a module.

**Simple lesson:** make a new app, router and store for every request.

## Hydration and Mismatches

- `createSSRApp().mount("#app")` **hydrates**: it walks the existing HTML and attaches Vue to it.
- The browser's first render must produce the **same** HTML as the server.
- If not, Vue reports a **hydration mismatch** and fixes the page, which is slow and can flicker.
- Common causes: the time, random numbers, `window` checks, and invalid HTML such as a `<div>` inside a `<p>`.

const rendered = Math.random() > 0.5 ? "server-ish" : "browser-ish";
const greeting = ref<string>("Welcome");

onMounted(() => {
    greeting.value = "Welcome back, " + (navigator.language || "friend");
});
</script>

<template>
    <p id="bad">Random: {{ rendered }}</p>
    <p id="allowed" data-allow-mismatch="text">Time: {{ new Date().toLocaleTimeString() }}</p>
    <p id="good">{{ greeting }}</p>
</template>
`,
    "main.ts": `import { createSSRApp } from "vue";

renderToString(createSSRApp(Clock)).then((html) => {
    document.getElementById("app")!.innerHTML = html.replace(/server-ish|browser-ish/, "from-server");
    setTimeout(() => createSSRApp(Clock).mount("#app"), 1100);
});
`,
  }}
/>

- **What happened:** the random text differed, so the Console shows a mismatch warning.
- The time also differed, but `data-allow-mismatch="text"` (Vue 3.5) told Vue it is expected.
- The greeting matched first, then changed in `onMounted`, which runs only in the browser. That is the clean fix.

### Lazy Hydration

- Hydrating a big page costs time, even for parts nobody uses yet.
- Vue 3.5 can hydrate async components **later**:
  - `hydrateOnIdle()`: when the browser is idle
  - `hydrateOnVisible()`: when it scrolls into view
  - `hydrateOnInteraction("click")`: on the first click
- Until then, the HTML is visible but not interactive.

const likes = ref<number>(0);
onMounted(() => console.log("Reviews hydrated now"));
</script>

<template>
    <p>"Crispy dosa!" <button @click="likes++">👍 {{ likes }}</button></p>
</template>
`,
    "App.vue": `<script setup lang="ts">

const Reviews = defineAsyncComponent({
    loader: () => import("./Reviews.vue"),
    hydrate: hydrateOnInteraction("click"),
});
</script>

<template>
    <h3>Reviews (click once to wake them up)</h3>
</template>
`,
    "main.ts": `import { createSSRApp } from "vue";

renderToString(createSSRApp(App)).then((html) => {
    document.getElementById("app")!.innerHTML = html;
    createSSRApp(App).mount("#app");
    console.log("Page hydrated, Reviews still waiting");
});
`,
  }}
/>

**Simple lesson:** server and first browser render must match. Put browser-only values in `onMounted`.

## Server Data and State Transfer

- `onMounted` does **not** run on the server. Data loaded there is missing from the HTML.
- `onServerPrefetch(async () => ...)` runs on the server before rendering. Load data there.
- The browser must not load the same data again. The server writes the data into the page as JSON.
- On hydration, the app reads that JSON instead of fetching. This is **state transfer**.

const dishes = inject("dishes") as Ref<string[]>;

onServerPrefetch(async () => {
    const res = await fetch("https://api.meeraskitchen.dev/dishes");
    dishes.value = ((await res.json()) as { name: string }[]).map((d) => d.name);
});
</script>

<template>
    <ul>
      <li v-for="d in dishes" :key="d">{{ d }}</li>
    </ul>
</template>
`,
    "main.ts": `import { createSSRApp } from "vue";

async function serverRender(): Promise<string> {
    const state = createState();
    const app = createSSRApp(Menu);
    app.provide("dishes", state.dishes);
    const html = await renderToString(app);
    const json = JSON.stringify(state.dishes.value).replace(/</g, "\\\\u003c");
    return \`\${html}<script>window.__STATE__ = \${json}<\\/script>\`;
}

serverRender().then((page) => {
    console.log("Server page:", page);
    const saved = JSON.parse(page.match(/__STATE__ = (.*)<\\/script>/)![1]) as string[];
    document.getElementById("app")!.innerHTML = page.replace(/<script>.*<\\/script>/, "");
    const app = createSSRApp(Menu);
    app.provide("dishes", createState(saved).dishes);
    app.mount("#app");
    console.log("Hydrated with", saved.length, "dishes and no second request");
});
`,
  }}
/>

- **What happened:** the Console shows one `GET /dishes`, made by the "server".
- The browser read `__STATE__` and hydrated without fetching again.

Escape `<` in JSON you put inside a `<script>` tag, as the box does with `<`. Otherwise a dish named `</script><script>…` could inject code into the page.

### Browser-Only Code and the SSR Lifecycle

- On the server, only `setup` and `onServerPrefetch` run. `onMounted`, `onUpdated` and `onUnmounted` do not.
- There is no `window`, `document` or `localStorage` on the server.
- Use them only inside `onMounted`, or behind `if (!import.meta.env.SSR)`.
- Server-only secrets, such as database passwords, must never be imported by components the browser also loads.

## Static Site Generation

- **SSG** = run SSR **once at build time** for every page, and save the HTML as files.
- A CDN serves those files. No server code runs per visit. It is very fast and cheap.
- Steps: list the routes, load data at build time, render each page, write `dist/menu/index.html` and so on.
- When the data changes, you **rebuild** and deploy again.
- Good for blogs, docs and marketing pages that change rarely.

defineProps({ title: { type: String, required: true }, body: { type: String, required: true } });
</script>

<template>
    <article><h1>{{ title }}</h1><p>{{ body }}</p></article>
</template>
`,
    "main.ts": `import { createSSRApp, h } from "vue";

const routes = [
    { path: "/", title: "Meera's Kitchen", body: "South Indian food since 2019." },
    { path: "/menu", title: "Menu", body: "Dosa, Idli, Vada, Pongal." },
    { path: "/contact", title: "Contact", body: "Call 98765 43210." },
];

async function build(): Promise<void> {
    const out = document.getElementById("app")!;
    for (const r of routes) {
      const html = await renderToString(createSSRApp({ render: () => h(Page, { title: r.title, body: r.body }) }));
      const file = r.path === "/" ? "dist/index.html" : \`dist\${r.path}/index.html\`;
      console.log("wrote", file, html.length, "bytes");
      out.insertAdjacentHTML("beforeend", \`<p><b>\${file}</b><br><code></code></p>\`);
      out.lastElementChild!.querySelector("code")!.textContent = html;
    }
}

build();
`,
  }}
/>

## How a Real SSR Project Is Wired

- A Vite SSR project has **two entry files** and a small server.
- `main.ts` exports a `createApp()` **function**, so each request gets a fresh app.
- `entry-server.ts` renders a URL to HTML. `entry-client.ts` hydrates it in the browser.
- `server.js` reads `index.html`, puts the rendered HTML in it, and sends it.

```ts
// src/main.ts: shared by server and browser
import { createSSRApp } from "vue";
import App from "./App.vue";
import { createRouter } from "./router";

export function createApp() {
  const app = createSSRApp(App);
  const router = createRouter(); // createMemoryHistory on the server, createWebHistory in the browser
  app.use(router);
  return { app, router };
}
```

```ts
// src/entry-server.ts: runs on the server for every request
import { renderToString } from "vue/server-renderer";
import { createApp } from "./main";

export async function render(url: string) {
  const { app, router } = createApp();
  await router.push(url);
  await router.isReady();
  const ctx = {};
  const html = await renderToString(app, ctx);
  return { html, ctx };
}
```

```ts
// src/entry-client.ts: runs in the browser
import { createApp } from "./main";

const { app, router } = createApp();
router.isReady().then(() => app.mount("#app")); // hydrates the server HTML
```

```js
// server.js (Express, simplified)
app.use("*", async (req, res) => {
  const { html, ctx } = await render(req.originalUrl);
  const page = template
    .replace("<!--app-head-->", ctx.head ?? "")
    .replace("<!--app-html-->", html);
  res.status(200).set({ "Content-Type": "text/html" }).end(page);
});
```

- Build both halves with `vite build` (browser) and `vite build --ssr src/entry-server.ts` (server).
- Most teams let **Nuxt** do all of this. You will meet it in **Nuxt: Full-Stack Vue**.

### Head Tags and SEO

- Search engines and link previews read `<title>` and `<meta>` tags from the server HTML.
- A component can add tags through the **SSR context**: `useSSRContext()` returns the `ctx` object passed to `renderToString`.
- The server then puts `ctx.head` into the page template.

const props = defineProps({ dish: { type: String, required: true } });

// The SSR context exists only while the server renders.
const ctx = useSSRContext<{ head?: string }>();
if (ctx) {
    ctx.head = \`<title>\${props.dish} | Meera's Kitchen</title>\\n<meta name="description" content="Order \${props.dish} online.">\`;
}
</script>

<template>
    <h1>{{ dish }}</h1>
    <p>Freshly made, every morning.</p>
</template>
`,
    "main.ts": `import { createSSRApp, h } from "vue";

const template = \`<html>
<head>
<!--app-head-->
</head>
<body><div id="app"><!--app-html--></div></body>
</html>\`;

async function serve(dish: string): Promise<string> {
    const ctx: { head?: string } = {};
    const html = await renderToString(createSSRApp({ render: () => h(DishPage, { dish }) }), ctx);
    return template.replace("<!--app-head-->", ctx.head ?? "").replace("<!--app-html-->", html);
}

serve("Masala Dosa").then((page) => {
    const pre = document.createElement("pre");
    pre.textContent = page;
    document.getElementById("app")!.appendChild(pre);
    console.log("The page a search engine sees has its own title.");
});
`,
  }}
/>

- **What happened:** the component set `ctx.head`, and the server put the title and description into `<head>`.
- In Nuxt, the same job is one line: `useHead({ title: "Masala Dosa" })` or `useSeoMeta()`.

`useSSRContext()` gives `undefined` in the browser, so the `if (ctx)` check keeps this component safe on both sides. In a real project, wrap server-only code in `if (import.meta.env.SSR)` so Vite drops it from the browser bundle.

## Streaming SSR

- `renderToString` waits for the **whole** page before sending anything.
- **Streaming** sends the HTML in pieces, as soon as each piece is ready.
- The visitor sees the top of the page while slow parts, such as reviews, are still loading.
- Vue gives `renderToNodeStream` / `pipeToNodeWritable` for Node.js and `renderToWebStream` for edge servers.

// An async setup: the server waits for it before sending this part.
await new Promise((r) => setTimeout(r, 900));
const reviews: string[] = ["Crispy dosa! ★★★★★", "Best filter coffee ★★★★"];
</script>

<template>
    <ul><li v-for="r in reviews" :key="r">{{ r }}</li></ul>
</template>
`,
    "Page.vue": `<script setup lang="ts">
</script>

<template>
    <header><h2>Meera's Kitchen</h2><p>Open 7 am to 10 pm</p></header>
    <footer>Call 98765 43210</footer>
</template>
`,
    "main.ts": `import { createSSRApp } from "vue";

async function stream(): Promise<void> {
    const out = document.getElementById("app")!;
    const reader = renderToWebStream(createSSRApp(Page)).getReader();
    const decoder = new TextDecoder();
    const start = performance.now();
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      const piece = typeof value === "string" ? value : decoder.decode(value);
      const ms = Math.round(performance.now() - start);
      console.log(\`+\${ms} ms: sent \${piece.length} characters\`);
      const p = document.createElement("pre");
      p.textContent = \`+\${ms} ms  \${piece}\`;
      out.appendChild(p);
    }
    console.log("Stream finished.");
}

stream();
`,
  }}
/>

- **What happened:** the header arrived at about **+0 ms**. The reviews and footer came about **900 ms** later.
- With `renderToString`, the visitor would see nothing for those 900 ms.

Async setup inside a page needs a `<Suspense>` boundary in the browser. On the server, the renderer simply waits for it.

## Hybrid Rendering

- Real sites mix modes: a static home page, a cached menu, a private dashboard.
- **Hybrid rendering** = a different mode for each route.
- **Route rules** pick the mode by path. Nuxt reads them from `nuxt.config.ts`:

```ts
// nuxt.config.ts (a real Nuxt project)
export default defineNuxtConfig({
  routeRules: {
    "/": { prerender: true },          // SSG: built once, served as a file
    "/menu": { swr: 60 },              // SSR, cached; refreshed in the background after 60 s
    "/offers": { isr: 3600 },          // like swr, but cached on the host's CDN
    "/admin/**": { ssr: false },       // CSR: an empty shell, the browser renders it
  },
});
```

- **SWR (stale-while-revalidate)** = serve the cached page at once, and if it is too old, render a new one in the background for the **next** visitor.
- **ISR (incremental static regeneration)** = the same idea, but the cache lives on the hosting CDN (Vercel, Netlify).
- **Edge rendering** = SSR on servers close to the visitor (Cloudflare Workers, Vercel Edge). Faster answers, but no Node.js-only packages.

The box below is a tiny pretend server that follows these rules. Press the buttons and read the log.

defineProps({
    path: { type: String, required: true },
    at: { type: Number, required: true },
    n: { type: Number, required: true },
});
</script>

<template>
    <p>{{ path }} made at t={{ at }}s (render #{{ n }})</p>
</template>
`,
    "server.ts": `import { createSSRApp, h } from "vue";

interface Rule {
    prerender?: boolean;
    swr?: number;
    ssr?: boolean;
}

    "/": { prerender: true },
    "/menu": { swr: 60 },
    "/admin": { ssr: false },
};

let now = 0; // pretend clock, in seconds
let renders = 0;
const files = new Map<string, string>();
const cache = new Map<string, { html: string; at: number }>();

    now += seconds;
}

async function render(path: string): Promise<string> {
    renders++;
    return renderToString(createSSRApp({ render: () => h(Page, { path, at: now, n: renders }) }));
}

    for (const [path, rule] of Object.entries(routeRules)) {
      if (rule.prerender) files.set(path, await render(path));
    }
    return \`build: prerendered \${files.size} page(s)\`;
}

    const rule = routeRules[path] ?? {};
    if (rule.prerender) return \`SSG file → \${files.get(path)}\`;
    if (rule.ssr === false) return \`CSR shell → <div id="app"></div> (the browser renders it)\`;
    if (rule.swr) {
      const hit = cache.get(path);
      if (!hit) {
        const html = await render(path);
        cache.set(path, { html, at: now });
        return \`SWR miss, rendered now → \${html}\`;
      }
      const age = now - hit.at;
      if (age <= rule.swr) return \`SWR fresh hit (age \${age}s) → \${hit.html}\`;
      render(path).then((html) => cache.set(path, { html, at: now }));
      return \`SWR stale (age \${age}s), refreshing in background → \${hit.html}\`;
    }
    return \`SSR every request → \${await render(path)}\`;
}
`,
    "App.vue": `<script setup lang="ts">

const log = ref<string[]>([]);

async function get(path: string): Promise<void> {
    log.value.push(\`GET \${path}: \${await handle(path)}\`);
}

function later(): void {
    wait(90);
    log.value.push("… 90 seconds pass …");
}

onMounted(async () => log.value.push(await build()));
</script>

<template>
    <button @click="get('/')">GET /</button>
    <button @click="get('/menu')">GET /menu</button>
    <button @click="get('/admin')">GET /admin</button>
    <button @click="get('/about')">GET /about</button>
    <button @click="later">Wait 90 s</button>
    <ol><li v-for="(line, i) in log" :key="i"><code>{{ line }}</code></li></ol>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

- Try: **GET /menu** twice (miss, then fresh hit). Then **Wait 90 s** and **GET /menu** twice more (stale, then the new copy).
- **GET /** always returns the file built at t=0. **GET /about** has no rule, so it renders every time.

<div className="tscroll">

| Mode | HTML made | Good for | Costs |
|---|---|---|---|
| CSR | In the browser | Apps behind a login | Slow first view, weak SEO |
| SSR | On the server, each request | Fresh or personal data | A server, more care with state |
| SSR + SWR/ISR | On the server, then cached | Menus, product pages | Visitors may see a page up to N seconds old |
| SSG | At build time | Docs, blogs, marketing | Rebuild when content changes |
| Hybrid | A mode per route | Most real websites | A framework such as Nuxt |

</div>

**Simple lesson:** pick the mode per page: SSG for static, SSR with a cache for shared data, SSR for personal data, CSR for private apps.

## TL;DR

- **SSR + `renderToString(app)`** = the server runs Vue and sends finished HTML.
- **Hydration** = `createSSRApp().mount()` attaches Vue to existing HTML. Mismatch? Use `onMounted` or `data-allow-mismatch`.
- **Lazy hydration** = `hydrateOnIdle`, `hydrateOnVisible`, `hydrateOnInteraction` (Vue 3.5).
- **Fresh app per request** = avoids cross-request state pollution.
- **`onServerPrefetch` + state transfer** = load on the server, send the data as JSON.
- **`useSSRContext()`** = lets components add head tags for the server page.
- **Streaming** = `renderToWebStream` sends HTML in pieces as they are ready.
- **SSG / SWR / ISR** = build once / serve cached and refresh in the background.
- **Hybrid** = a mode per route, with route rules.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Module-level state on the server | Each visitor gets fresh modules | Create app, router and store per request | Users see each other's data |
| Random values or time in templates | Server and browser agree | Set them in `onMounted` | Hydration mismatch warnings |
| `window` in setup | The server has a window | Use it in `onMounted` | The server crashes |
| Fetching in `onMounted` for SEO pages | The HTML will include it | Use `onServerPrefetch` | Search engines see an empty list |
| Fetching again after hydration | Two requests are harmless | Transfer state as JSON | Slower pages and flicker |
| Unescaped JSON in a script tag | JSON is always safe | Escape `<` as `\u003c` | Script injection |
| SSG for data that changes every minute | Static is always best | Use SSR or a cache rule | Stale pages until the next build |

</div>

## Check Yourself

## Exercises

## What to Remember

- SSR sends HTML first. Hydration makes it interactive.
- Create a fresh app, router and store per request.
- The first browser render must match the server. Browser-only values go in `onMounted`.
- Load server data in `onServerPrefetch`, and transfer it as escaped JSON.
- Pick per page: SSG for static, SSR for fresh, CSR for private apps.

Next: **Nuxt: Full-Stack Vue**
