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

  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

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, window checks, 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 idle
    • hydrateOnVisible(): when it scrolls into view
    • hydrateOnInteraction("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

  • 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.
  • 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.

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.
// 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) 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.
  • 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.
  • 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.
ModeHTML madeGood forCosts
CSRIn the browserApps behind a loginSlow first view, weak SEO
SSROn the server, each requestFresh or personal dataA server, more care with state
SSR + SWR/ISROn the server, then cachedMenus, product pagesVisitors may see a page up to N seconds old
SSGAt build timeDocs, blogs, marketingRebuild when content changes
HybridA mode per routeMost real websitesA 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? 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

MistakeWrong ideaCorrect ideaWhat goes wrong
Module-level state on the serverEach visitor gets fresh modulesCreate app, router and store per requestUsers see each other's data
Random values or time in templatesServer and browser agreeSet them in onMountedHydration mismatch warnings
window in setupThe server has a windowUse it in onMountedThe server crashes
Fetching in onMounted for SEO pagesThe HTML will include itUse onServerPrefetchSearch engines see an empty list
Fetching again after hydrationTwo requests are harmlessTransfer state as JSONSlower pages and flicker
Unescaped JSON in a script tagJSON is always safeEscape < as \u003cScript injection
SSG for data that changes every minuteStatic is always bestUse SSR or a cache ruleStale 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