I built an SSR project with Nuxt.js in the first half of the year. Using it was smooth, but when problems arose I didn't know where to start. After digging into Vue SSR's internals, here's a summary of the core mechanisms.
SSR vs CSR Rendering Differences
CSR (Client-Side Rendering):
1. Browser requests HTML → server returns empty HTML
2. Browser loads JS → Vue runs on the client
3. Vue creates VNodes → diff → renders DOM
4. User sees content (time to first paint = JS execution time)
SSR (Server-Side Rendering):
1. Browser requests HTML → server runs Vue
2. Vue generates HTML string on the server → sends to browser
3. Browser displays HTML (first screen immediately visible)
4. Browser loads JS → Vue "takes over" existing DOM (Hydration)
5. Page becomes an interactive SPA
Core API: vue-server-renderer
{% raw %}
const Vue = require("vue");
const renderer = require("vue-server-renderer").createRenderer();
const app = new Vue({
template: `<div>Hello, {{ name }}!</div>`,
data: { name: "World" },
});
renderer.renderToString(app, (err, html) => {
console.log(html);
// <div data-server-rendered="true">Hello, World!</div>
});
{% endraw %}
The data-server-rendered="true" marker tells the client-side Vue that this DOM was server-rendered and can be reused without recreating it.
Why You Need Separate Client and Server Entry Points
An SSR application needs two bundles:
Server bundle (Node.js environment):
- Handles SSR rendering requests
- No
window,document, or other browser APIs - Each request gets a fresh application instance (avoids state pollution)
Client bundle (browser environment):
- A normal SPA bundle
- Responsible for hydrating the server-rendered DOM
- Handles routing navigation, interactions, etc.
// Application factory function (returns a new instance each call to avoid state pollution)
// app.js
import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";
import createStore from "./store";
export function createApp() {
const router = createRouter();
const store = createStore();
const app = new Vue({
router,
store,
render: (h) => h(App),
});
return { app, router, store };
}
// entry-server.js
import { createApp } from "./app";
export default (context) => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp();
router.push(context.url);
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
if (!matchedComponents.length) {
return reject({ code: 404 });
}
// Call asyncData on components to fetch data
Promise.all(
matchedComponents.map((component) => {
if (component.asyncData) {
return component.asyncData({ store, route: router.currentRoute });
}
}),
)
.then(() => {
// Embed store state in HTML (client uses it for initialization)
context.state = store.state;
resolve(app);
})
.catch(reject);
}, reject);
});
};
Hydration: Client Takeover
During client initialization, Vue checks whether the server-rendered DOM matches the virtual DOM. If it matches, it reuses it without recreating:
// entry-client.js
import { createApp } from "./app";
const { app, router, store } = createApp();
// Initialize store from server-embedded state
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}
router.onReady(() => {
app.$mount("#app"); // mount to existing DOM, triggers Hydration
});
Common Issues
1. Using window/document in SSR
// ❌ window doesn't exist on the server
if (window.innerWidth < 768) { ... }
// ✅ Check the runtime environment
if (typeof window !== 'undefined') {
// browser only code
}
// ✅ Or put it in mounted (only runs on client)
mounted() {
if (window.innerWidth < 768) { ... }
}
2. Hydration Mismatch
Inconsistent server/client render results cause hydration warnings:
{% raw %}
<!-- ❌ Content that depends on client state -->
<template>
<div>{{ Date.now() }}</div>
<!-- server and client times differ -->
</template>
<!-- ✅ Ensure consistency -->
<template>
<div>{{ formattedDate }}</div>
</template>
<script>
export default {
asyncData({ store }) {
store.commit("SET_TIMESTAMP", Date.now());
},
};
</script>
{% endraw %}
3. Third-party Library Compatibility
Many libraries assume a browser environment and crash in SSR. Solutions:
- Use conditional checks to skip server-side execution
- Use
ssr: falseplugins (Nuxt.js)
Summary
- The core of SSR: server renders HTML string + client hydration
- Application must be written as a factory function, each request gets its own instance