Vue.js 74 🟢 Server-Side Rendering (SSR) Architecture with Vue (@vue/server-renderer)
Server-side rendering in Vue is not a framework feature that you enable with a flag. It is an architectural pattern that requires two coordinated entry points—one for the server and one for the client—and a renderer that converts a Vue application instance into an HTML string. The @vue/server-renderer package provides that renderer, and its API surface is deliberately small: renderToString for the common case, streaming variants for large responses, and a context object for collecting teleported content. The complexity lives not in the renderer but in the coordination: the server must render the correct application state for each request, the client must hydrate that HTML without mismatches, and both must share the same application factory.
Vue’s documentation is explicit about the scope of the raw renderer. The renderToString function renders an application to a string, but a production SSR setup requires coordinating two builds (client and server), supporting Single-File Components in both, managing routing and data fetching universally, and injecting the correct client-side asset links into the HTML shell . The Vue team recommends higher-level solutions—Nuxt, Quasar, or Vite SSR with vite-plugin-ssr—for production applications. The @vue/server-renderer package is the primitive those frameworks build on, and understanding it requires understanding both what it does and what it deliberately leaves to the application.
This chapter covers the renderToString API and the SSRContext object, the streaming APIs (pipeToNodeWritable, renderToWebStream, renderToSimpleStream), the SSR-specific component lifecycle that disables reactivity and skips mounted hooks, and the hydration mismatch causes that make SSR debugging painful. It assumes familiarity with Vue 3’s application factory pattern and the distinction between server and client environments.
Key point: renderToString renders a Vue app to an HTML string, but the app must be created per-request with createSSRApp, not createApp. The renderer does not fetch data, manage routing, or serialize state—those are application responsibilities, and the hydration step on the client depends on the server and client producing identical HTML from identical state.
Why SSR requires an architectural shift
The renderer is stateless; the application is not. A Vue application created with createSSRApp has no DOM to mount to on the server. It renders once, produces a string, and is discarded. This means the application factory must be called for every request, not once at module load. A module-level const app = createSSRApp(App) shared across requests is a classic SSR bug: state from one request leaks into the next, and the rendered HTML depends on request ordering .
Reactivity is disabled on the server. During SSR, there are no user interactions and no DOM updates. Vue disables reactivity for performance, and lifecycle hooks like mounted and updated are never called. The only hooks that run are beforeCreate and created, or the synchronous portion of setup() . This has a concrete consequence: code that sets up timers or event listeners in setup() and tears them down in onUnmounted will leak on the server, because onUnmounted never runs. Side effects belong in onMounted, which only executes on the client.
The SSRContext is the escape hatch for teleports. Vue’s <Teleport> moves content to a different part of the DOM. On the server, that content is not part of the main rendered string. The SSRContext object passed to renderToString records teleported content under ctx.teleports, allowing the server to inject it into the HTML shell at the correct target . Without this, a teleported modal renders on the client but is absent from the server HTML, causing a hydration mismatch.
The trade-off. SSR improves initial load time and SEO by delivering fully rendered HTML, but it adds a server runtime, a second build target, and a class of bugs—hydration mismatches—that do not exist in client-only applications. The renderer handles the string conversion; the application must handle everything else.
a. renderToString and the SSRContext
The primary API is renderToString(input, context?). The input is a Vue application instance created with createSSRApp, not a component definition. The optional context is an object that the renderer populates with data collected during rendering, most notably teleports .
import { createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
// The application factory must be called per request.
function createApp() {
return createSSRApp({
data: () => ({ msg: 'hello' }),
template: `<div>{{ msg }}</div>`,
});
}
// Inside a request handler:
const app = createApp();
const ctx = {};
const html = await renderToString(app, ctx);
// Teleported content is recorded in ctx.teleports.
console.log(ctx.teleports); // { '#target': 'teleported html' }
The useSSRContext helper allows component code to access the same context object during rendering. This is how a component attaches head metadata, serialized state, or other information that the server needs to inject into the HTML shell . The helper returns undefined on the client, so component code must guard against that.
import { useSSRContext } from 'vue';
// Inside setup(), during SSR only:
if (import.meta.env.SSR) {
const ctx = useSSRContext();
// Attach data for the HTML shell.
ctx.head = { title: 'Page Title' };
}
The renderer does not serialize application state. If the application fetches data during SSR, that data must be serialized into the HTML (typically as window.__INITIAL_STATE__ or a similar global) and restored on the client before hydration. Pinia’s SSR documentation describes this explicitly: the store’s rootState must be serialized, escaped for XSS safety (the devalue library is recommended), and placed in the HTML for the client to pick up . The renderer produces HTML; the application produces and transfers state.
b. Streaming APIs for large responses
renderToString buffers the entire HTML before sending it. For large pages, this delays the first byte and holds the full response in memory. The streaming APIs address this by sending HTML in chunks as components render. The choice of API depends on the server environment.
pipeToNodeWritable renders and pipes to a Node.js Writable stream. It is the recommended Node.js API because the ESM build of vue/server-renderer does not support renderToNodeStream, which is tied to the CommonJS build .
import { pipeToNodeWritable } from 'vue/server-renderer';
// Inside a Node.js HTTP handler:
pipeToNodeWritable(app, {}, res);
renderToWebStream returns a Web ReadableStream, suitable for edge environments and modern server frameworks that support the Web Streams API. pipeToWebWritable pipes to an existing WritableStream, typically used with a TransformStream to produce a Response .
import { renderToWebStream } from 'vue/server-renderer';
// In an environment with ReadableStream support:
const stream = renderToWebStream(app);
return new Response(stream, { headers: { 'Content-Type': 'text/html' } });
renderToSimpleStream provides a minimal push/destroy interface for environments that lack both Node streams and Web streams. It is the lowest-level option and is rarely needed outside custom runtime integrations .
The streaming APIs share the same SSRContext parameter and the same requirement that the application be created per request. They differ only in how the rendered chunks are delivered.
c. SSR lifecycle constraints and hydration mismatches
The SSR renderer runs the component tree once, synchronously where possible, and produces a string. This execution model creates constraints that do not exist on the client, and violating them produces hydration mismatches—the server HTML and the client’s first render disagree, and Vue discards the mismatched nodes and re-renders, causing flickering, performance loss, and potentially broken event handlers .
The most common causes are platform-specific APIs, non-deterministic values, and invalid HTML. Accessing window or document during SSR throws because those globals do not exist in Node.js. The fix is to wrap browser-only code in onMounted, which only runs on the client, or to guard with import.meta.env.SSR . Random values and timestamps produce different output on server and client; they must be generated deterministically (seeded random) or deferred to onMounted . Invalid HTML—a <div> inside a <p>, nested <a> tags—is auto-corrected differently by the browser and by Vue’s renderer, producing a DOM that does not match the virtual DOM. The fix is valid HTML nesting .
Teleport components are a special case. The server renders teleported content into ctx.teleports, but the client expects it in the target container. If the server does not inject the teleported content into the HTML at the target location, the client’s hydration finds an empty target and a mismatch occurs. The correct pattern is to use the ctx.teleports data when constructing the HTML shell, placing the teleported content at the target selector . For components that should only render on the client, wrapping them in a client-only boundary (or using v-if="isMounted") avoids the mismatch entirely.
Vue 3.5 introduced data-allow-mismatch to suppress warnings for intentional mismatches, such as localized date formatting that differs between server timezone and client timezone . This is an acknowledgment that some mismatches are unavoidable and acceptable; the attribute prevents the warning without preventing the re-render.
Complete Example Session
// ============================================
// PART 1: SHARED APPLICATION FACTORY
// ============================================
import { createSSRApp } from 'vue';
import App from './App.vue';
// Called per request on server, once on client.
export function createApp() {
const app = createSSRApp(App);
// Register router, store, plugins here.
return { app };
}
// ============================================
// PART 2: SERVER ENTRY POINT
// ============================================
import { renderToString } from 'vue/server-renderer';
export async function render(url) {
const { app, router } = createApp();
// Navigate the router to the requested URL.
router.push(url);
await router.isReady();
const ctx = {};
const html = await renderToString(app, ctx);
return { html, teleports: ctx.teleports };
}
// ============================================
// PART 3: HTML SHELL WITH TELEPORT INJECTION
// ============================================
function buildHtml(template, html, teleports) {
let result = template.replace('<!--ssr-outlet-->', html);
// Inject teleported content at its target.
if (teleports) {
for (const [selector, content] of Object.entries(teleports)) {
result = result.replace(
`<!--teleport:${selector}-->`,
content
);
}
}
return result;
}
// ============================================
// PART 4: STREAMING FOR LARGE PAGES
// ============================================
import { pipeToNodeWritable } from 'vue/server-renderer';
// Node.js handler for large responses:
export function streamRender(app, res) {
res.setHeader('Content-Type', 'text/html');
pipeToNodeWritable(app, {}, res);
}
// ============================================
// PART 5: CLIENT HYDRATION ENTRY
// ============================================
// entry-client.js
import { createApp } from './app';
const { app } = createApp();
// Hydrate the server-rendered HTML.
app.mount('#app');
// ============================================
// PART 6: SSR-SAFE COMPONENT CODE
// ============================================
// Avoid this:
// const width = window.innerWidth; // Throws on server.
// Do this:
import { ref, onMounted } from 'vue';
const width = ref(0);
onMounted(() => {
width.value = window.innerWidth; // Client only.
});
// ============================================
// PART 7: DETERMINISTIC RENDERING
// ============================================
// Avoid random values in render.
// const id = 'field-' + Math.random(); // Mismatch.
// Use a stable value, randomize on client.
const id = ref('field-default');
onMounted(() => {
id.value = 'field-' + Math.random().toString(36).slice(2);
});
// ============================================
// PART 8: TELEPORT HANDLING
// ============================================
// Component with Teleport:
// <Teleport to="#modals">
// <div v-if="showModal">Modal</div>
// </Teleport>
//
// Server: ctx.teleports['#modals'] contains the HTML.
// Client: expects content at #modals.
// Inject ctx.teleports into the shell before #modals.
// ============================================
// PART 9: STATE SERIALIZATION
// ============================================
// After render, serialize store state:
// const state = JSON.stringify(pinia.state.value);
// Escape for XSS: use devalue or equivalent.
// Inject as <script>window.__STATE__ = ...</script>.
//
// On client, before app.mount():
// pinia.state.value = JSON.parse(window.__STATE__);
// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// Request → createApp() → router.push(url)
// → renderToString(app, ctx)
// → inject html + ctx.teleports + serialized state
// → send HTML.
//
// Client → load HTML → restore state
// → createApp() → app.mount('#app') → hydrate.
Quick Reference
SSR API Functions
| Function | Environment | Output |
|---|---|---|
renderToString | Any | Promise<string> |
pipeToNodeWritable | Node.js | Pipes to Writable |
renderToNodeStream | CommonJS Node only | Readable stream |
renderToWebStream | Web Streams | ReadableStream |
pipeToWebWritable | Web Streams | Pipes to WritableStream |
renderToSimpleStream | Any | push/destroy interface |
SSR Lifecycle Hooks
| Hook | Server | Client |
|---|---|---|
beforeCreate / created | Called | Called |
mounted / updated | Not called | Called |
onUnmounted | Not called | Called |
setup() synchronous | Called | Called |
| Reactivity | Disabled | Enabled |
Hydration Mismatch Causes
| Cause | Fix |
|---|---|
window/document in setup | Move to onMounted |
| Random values in render | Defer to onMounted or seed |
| Date/timezone formatting | Defer to client or use data-allow-mismatch |
| Invalid HTML nesting | Valid HTML |
| Teleport without injection | Use ctx.teleports in shell |
SSR Context Properties
| Property | Populated By | Use |
|---|---|---|
ctx.teleports | Renderer | Inject teleported HTML into shell |
ctx.modules | Renderer | Track rendered modules |
| Custom properties | useSSRContext() | Head metadata, state |
Best Practices
✅ Do This:
// Create app per request with createSSRApp. // ✅
function createApp() { return createSSRApp(App); }
// Use onMounted for browser APIs. // ✅
onMounted(() => { width.value = window.innerWidth; });
// Inject ctx.teleports into the HTML shell. // ✅
// Replace <!--teleport:#modals--> with ctx.teleports['#modals'].
// Serialize state with escaping (devalue). // ✅
// pinia.state.value → devalue → window.__STATE__.
// Use streaming for large pages. // ✅
pipeToNodeWritable(app, {}, res);
❌ Don’t Do This:
// Share app instance across requests. // ❌
const app = createSSRApp(App); // State leaks between requests.
// Access window in setup(). // ❌
const width = window.innerWidth; // Throws on server.
// Generate random IDs in render. // ❌
const id = 'field-' + Math.random(); // Hydration mismatch.
// Set up timers in setup() without cleanup. // ❌
setInterval(() => {}, 1000); // Leaks on server; onUnmounted never runs.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| State leaks between requests | App created once at module level | Create per request |
Hydration mismatch on mounted logic | Side effects in setup() | Move to onMounted |
| Teleport content missing | ctx.teleports not injected | Use context in HTML shell |
window is not defined | Browser API in server render | Guard with import.meta.env.SSR |
| Different HTML server vs client | Random values, timestamps | Deterministic render or defer |
onUnmounted never called on server | SSR has no unmount phase | Side effects in onMounted |
Real-World Examples
1. Basic renderToString
const html = await renderToString(createSSRApp(App));
2. Context with Teleports
const ctx = {};
await renderToString(app, ctx);
// ctx.teleports = { '#target': '<div>content</div>' };
3. Node.js Streaming
pipeToNodeWritable(app, {}, res);
4. Web Stream
return new Response(renderToWebStream(app));
5. SSR-Safe Browser API
onMounted(() => { theme.value = localStorage.getItem('theme'); });
6. Deterministic ID
const id = ref('field-default');
onMounted(() => { id.value = 'field-' + Math.random().toString(36).slice(2); });
7. State Serialization
import devalue from 'devalue';
const state = devalue(pinia.state.value);
8. useSSRContext for Head
if (import.meta.env.SSR) {
const ctx = useSSRContext();
ctx.head = { title: 'Page' };
}
9. Teleport Injection
result.replace('<!--teleport:#modals-->', ctx.teleports['#modals']);
10. Client Hydration
createSSRApp(App).mount('#app'); // Hydrates server HTML.
Visual
SSR Request Lifecycle
┌──────────────────────────────────────────────┐
│ SERVER REQUEST │
│ │
│ HTTP Request │
│ │ │
│ ▼ │
│ createSSRApp(App) ← per request │
│ │ │
│ ▼ │
│ router.push(url) → await router.isReady() │
│ │ │
│ ▼ │
│ renderToString(app, ctx) │
│ │ │
│ ├── Vue renders components to string │
│ ├── ctx.teleports populated │
│ └── No mounted hooks called │
│ │ │
│ ▼ │
│ Inject html + teleports + state into shell │
│ │ │
│ ▼ │
│ Send HTML to browser │
│ │
└──────────────────────────────────────────────┘
Hydration Flow
┌──────────────────────────────────────────────┐
│ CLIENT HYDRATION │
│ │
│ Browser receives HTML │
│ │ │
│ ▼ │
│ Restore serialized state │
│ │ │
│ ▼ │
│ createSSRApp(App) │
│ │ │
│ ▼ │
│ app.mount('#app') │
│ │ │
│ ▼ │
│ Vue walks existing DOM, attaches handlers │
│ │ │
│ ├── Match: reuse DOM nodes │
│ └── Mismatch: discard, re-render │
│ │ │
│ ▼ │
│ onMounted hooks run → app interactive │
│ │
└──────────────────────────────────────────────┘
Teleport SSR Handling
┌──────────────────────────────────────────────┐
│ TELEPORT SSR │
│ │
│ Component: │
│ <Teleport to="#modals"> │
│ <div v-if="show">Modal</div> │
│ </Teleport> │
│ │
│ Server render: │
│ Main HTML: <div id="app">...</div> │
│ ctx.teleports['#modals'] = '<div>Modal</div>'│
│ │
│ HTML shell: │
│ <div id="app"><!--ssr-outlet--></div> │
│ <div id="modals"><!--teleport:#modals--></div>│
│ │
│ Inject: replace teleport comment with content│
│ │
│ Client: finds content at #modals → match │
│ │
└──────────────────────────────────────────────┘
SSR Lifecycle vs Client
┌──────────────────────────────────────────────┐
│ LIFECYCLE HOOKS │
│ │
│ setup() │
│ ├── Server: runs (synchronous) │
│ └── Client: runs (synchronous) │
│ │
│ beforeCreate / created │
│ ├── Server: runs │
│ └── Client: runs │
│ │
│ mounted / onMounted │
│ ├── Server: NOT CALLED │
│ └── Client: runs │
│ │
│ onUnmounted │
│ ├── Server: NOT CALLED │
│ └── Client: runs │
│ │
│ Rule: side effects in onMounted, │
│ not setup(). │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Core API | renderToString(app, ctx?) |
| App factory | createSSRApp, called per request |
| SSRContext | Records teleports, head data |
| Streaming (Node) | pipeToNodeWritable |
| Streaming (Web) | renderToWebStream, pipeToWebWritable |
| Server hooks | beforeCreate, created, synchronous setup |
| Client-only hooks | mounted, updated, onUnmounted |
| Reactivity on server | Disabled |
| Teleport handling | ctx.teleports + shell injection |
| Hydration | app.mount('#app') reuses server DOM |
Key takeaways:
renderToStringrenders an app, not a component. The input must be acreateSSRAppinstance, and that instance must be created per request. A module-level app instance leaks state between requests .- The
SSRContextcollects teleported content.<Teleport>content is not in the main HTML string.ctx.teleportsrecords it, and the server must inject it into the HTML shell at the target selector, or the client hydration will mismatch . - Reactivity and client lifecycle hooks are disabled on the server.
mountedandonUnmountednever run during SSR. Side effects—timers, event listeners, browser API access—belong inonMounted, which only executes on the client . - Streaming APIs deliver HTML incrementally.
pipeToNodeWritablefor Node.js,renderToWebStreamfor edge/Web environments. The choice depends on the server runtime, not the application . - Hydration mismatches are the primary SSR bug class. Random values, timestamps, browser APIs, and invalid HTML produce different server and client output. Vue discards mismatched nodes and re-renders, causing flickering and performance loss .
- State must be serialized and transferred. The renderer produces HTML; the application must serialize its store state (with XSS-safe escaping), inject it into the HTML, and restore it before hydration .
- The raw renderer is a primitive, not a framework. Production SSR requires coordinating builds, routing, data fetching, and asset injection. Nuxt and Vite SSR provide these abstractions;
@vue/server-rendereris what they build on .
Remember: @vue/server-renderer converts a Vue application instance into HTML, but it does not solve the coordination problem that SSR introduces. The application must be created per request, its state must be serialized and restored, its teleported content must be injected into the shell, and its components must avoid browser APIs in code that runs during rendering. The renderer’s API is small—renderToString and its streaming variants—because the complexity belongs to the application architecture, not the string conversion. Hydration is the verification step: the client walks the server HTML and checks that its virtual DOM matches. When it does not, Vue discards the mismatch and re-renders, and the performance benefit of SSR is lost. The renderer is the easy part; the discipline is the hard part.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!