Server and Static Rendering
By Syncfusion®, Inc. · Last updated
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.
- 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
- The SSR request cycle.
- Why each request needs a fresh app.
- What hydration is, and what a mismatch is.
- How to fix or allow a mismatch.
- Lazy hydration in Vue 3.5.
- Loading data on the server, and sending it to the browser.
- Browser-only code and the SSR lifecycle.
- Static site generation and rebuilds.
- How a Vite SSR project is wired, and head tags for SEO.
- Streaming SSR.
- Hybrid rendering with SWR, ISR and edge, and how to choose a mode.
The SSR Request Cycle
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
Browser
Server (Node.js)
1. createSSRApp() 2. load data 3. renderToString() 4. show HTML now 5. load JS 6. hydrate interactive ✓
GET /menu
The server builds a fresh app per request and sends HTML. The browser shows it, then hydrates.
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.
- 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,
windowchecks, and invalid HTML such as a<div>inside a<p>.
- 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 idlehydrateOnVisible(): when it scrolls into viewhydrateOnInteraction("click"): on the first click
- Until then, the HTML is visible but not interactive.
Simple lesson: server and first browser render must match. Put browser-only values in onMounted.
Server Data and State Transfer
onMounteddoes 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.
- 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
setupandonServerPrefetchrun.onMounted,onUpdatedandonUnmounteddo not. - There is no
window,documentorlocalStorageon the server. - Use them only inside
onMounted, or behindif (!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.htmland so on. - When the data changes, you rebuild and deploy again.
- Good for blogs, docs and marketing pages that change rarely.
How a Real SSR Project Is Wired
- A Vite SSR project has two entry files and a small server.
main.tsexports acreateApp()function, so each request gets a fresh app.entry-server.tsrenders a URL to HTML.entry-client.tshydrates it in the browser.server.jsreadsindex.html, puts the rendered HTML in it, and sends it.
// 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 };
}
// 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 };
}
// 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
// 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) andvite 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 thectxobject passed torenderToString. - The server then puts
ctx.headinto the page template.
- 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" })oruseSeoMeta().
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
renderToStringwaits 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/pipeToNodeWritablefor Node.js andrenderToWebStreamfor edge servers.
- 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:
// 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.
- 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.
| 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 |
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? UseonMountedordata-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 =
renderToWebStreamsends 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
| 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 |
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