SSR
SSR (Server-Side Rendering) is a rendering method that converts Vue components into HTML strings on the server and sends them directly to the browser.
In traditional CSR (Client-Side Rendering), the browser receives an "empty shell" HTML and a bunch of JS files. The page content will not be displayed until the JS is downloaded and executed. In contrast, SSR allows the page to contain complete content when it arrives at the browser.
Isomorphism
SSR does not completely abandon client-side rendering, but rather combines both.
Server:
- Receives browser requests.
- Converts Vue components into HTML strings using
@vue/server-renderer(or Vite SSR plugin). - Injects the HTML into the template and returns it to the browser.
Browser:
- Receives the page containing the complete HTML, allowing users to see the content immediately.
- Hydration: This is the key step. The browser downloads the Vue JS code, and Vue takes over the existing HTML, binding event listeners to it, making it an interactive Single Page Application (SPA).
Disadvantages
Development constraints:
- Only
beforeCreateandcreatedlifecycle hooks are executed on the server;mountedand others will not be triggered on the server. - You cannot directly use browser-specific APIs (such as
window,document,localStorage). You must check the environment in specific hooks.
- Only
Server load: The server needs to run Vue instances in real-time and render HTML, consuming more CPU and memory than simply serving static files (CSR).
Complex deployment: Requires a Node.js runtime environment, and involves handling cache strategies, reverse proxy (like Nginx) configurations, etc.
Handwritten SSR
Based on Vite + Express
Directory
my-vite-ssr-app/
├── index.html # HTML Template (contains SSR injection placeholder)
├── server.js # Express server main file (handles HTTP requests and Vite middleware)
├── package.json
├── vite.config.js # Vite Config
├── src/
│ ├── main.js # Common creation logic (exports createApp for both SSR and client)
│ ├── entry-client.js # Client entry (mounts App to DOM, activates interaction)
│ ├── entry-server.js # Server entry (converts App to HTML string)
│ ├── router/
│ │ └── index.js # Router code (distinguishes History mode)
│ ├── views/ # Page components
│ │ ├── Home.vue
│ │ └── About.vue
│ ├── App.vue # Root component
│ └── assets/ # Static resources (CSS, Images)
└── dist/ # Bundled directory (generated after build)
├── client/ # Client static resources
└── server/ # Server rendering bundleCode
import { createClientApp } from './main'
const { app, router } = createClientApp()
// Wait for router to be ready before mounting to prevent hydration mismatch
router.isReady().then(() => {
app.mount('#app')
})import { renderToString } from 'vue/server-renderer'
import { createServerApp } from './main'
export async function render(url) {
const { app, router } = createServerApp()
router.push(url)
await router.isReady()
const ctx = {}
const html = await renderToString(app, ctx)
return html
}import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router'
export function createServerApp() {
const app = createSSRApp(App)
const router = createRouter()
app.use(router)
return { app, router }
}
export function createClientApp() {
const app = createSSRApp(App)
const router = createRouter()
app.use(router)
return { app, router }
}{
"name": "vue-ssr",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"express": "^4.2.1",
"pinia": "^2.2.0",
"prettier": "^3.3.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.2"
}
}import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import express from 'express'
import { createServer as createViteServer } from 'vite'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function createServer() {
const app = express()
// Create Vite development server instance (middleware mode)
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom'
})
// Use vite's connect instance as middleware
app.use(vite.middlewares)
app.use('*', async (req, res) => {
const url = req.originalUrl
try {
// 1. Read index.html
let template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
)
// 2. Apply Vite HTML transforms (handles HMR and hot update scripts)
template = await vite.transformIndexHtml(url, template)
// 3. Load the server entry
// vite.ssrLoadModule automatically transforms ESM to Node.js runnable code
const { render } = await vite.ssrLoadModule('/src/entry-server.js')
// 4. Render the app HTML
const appHtml = await render(url)
// 5. Inject the app-rendered HTML into the template
const html = template.replace(
'<div id="app"></div>',
`<div id="app">${appHtml}</div>`
)
// 6. Send the rendered HTML back
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
// If an error is caught, let Vite fix the stack trace
vite.ssrFixStacktrace(e)
console.error(e)
res.status(500).end(e.message)
}
})
console.log('Server started at http://localhost:5173')
app.listen(5173)
}
createServer()import {
createRouter as _createRouter,
createMemoryHistory,
createWebHistory
} from 'vue-router'
const routes = [
{
path: '/',
component: () => import('../views/Home.vue')
},
{
path: '/about',
component: () => import('../views/About.vue')
}
]
export function createRouter() {
return _createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes
})
}<script setup></script>
<template>
<div>
<nav>
<RouterLink to="/">Home | </RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
<RouterView></RouterView>
</div>
</template>
<style scoped></style><!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vite SSR</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/entry-client.js"></script>
</body>
</html>import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()]
})Detail
The execution process can be divided into three main stages:
1. Server Startup
- Execution entry: Run the command, Node.js starts executing
server.js. - Initialize server: In
server.js, anExpressinstance is created, and theViteservice is started in middleware mode.Vitehandles static resources and hot updates, whileExpresshandles page routing. - Listen to port: The server starts listening on port
5173, waiting for user access.
2. Handle Browser Requests
When you open localhost:5173 in your browser:
Intercept request:
app.use('*', async(req,res) => {})ofExpressintercepts your request.Read template: Reads
index.htmlfrom the project root directory.Load server entry: Dynamically loads
entry-server.jsviaVite'sssrLoadModule.Create
Vueinstance:entry-server.jscallscreateServerApp()inmain.js.main.jscreates and returns a brand new Vue instance and Router instance.
Match route and render:
entry-server.jsnavigates the route to the corresponding page viarouter.push(url)based on the requested URL (e.g.,/or/about).- After matching the page component (e.g.,
Home.vue), it usesrenderToStringto render this Vue component tree into a pure static HTML string.
Return HTML:
server.jsinjects this HTML string into the<div id="app"></div>placeholder ofindex.html, and then sends the complete HTML to the browser.
3. Client Takeover and Hydration
Initial browser rendering: The browser receives the HTML and immediately displays the page (the page is visible, but interactions like buttons have not yet taken effect because JS events have not been mounted).
Load client script: When the browser parses the HTML, it finds
<script type="module" src="/src/entry-client.js"></script>(defined in index.html), so it starts requesting and executing the client code.Client initialization:
- Executes
entry-client.js. - It calls
createClientApp()inmain.js, creating anotherVueinstance andRouterinstance on the browser side.
- Executes
Activate page (Hydration):
- After the client router is ready, executes
app.mount("#app"). - Vue will inspect the existing DOM (the HTML just sent by the server) and will not regenerate the DOM. Instead, it "binds" event listeners (like
@clickof a button) to the existing static DOM. - At this point, the page is completely "alive", and subsequent route navigations (like clicking
About) are completely taken over byVue Routeron the frontend, turning into a traditional SPA (Single Page Application) experience.
- After the client router is ready, executes
⚠️ Advanced & Common Issues (Important)
1. Hydration Mismatch
If the virtual DOM generated by the client is inconsistent with the actual DOM structure returned by the server, Vue will throw a Hydration Mismatch error.
Common Causes:
- Using indeterminate data: For example, using
Math.random()or current timenew Date()during component rendering, causing the server-rendered value to be different from the client-rendered value. - Irregular HTML structure: For example, nesting block-level elements (like
<div>) inside<p>tags. The browser automatically corrects the HTML structure, which causes the client to fail to find the corresponding DOM during hydration. - Environment-specific variables: Using
typeof window !== 'undefined'for rendering logic, causing the server and client to enter different branches.
Solution: Put client-only logic into the onMounted hook, because onMounted is only executed after client hydration is complete.
<script setup>
import { ref, onMounted } from 'vue'
const isClient = ref(false)
onMounted(() => {
isClient.value = true
})
</script>
<template>
<div v-if="isClient">
<!-- This is only rendered on the client side to avoid structural inconsistency -->
<p>{{ Math.random() }}</p>
</div>
</template>2. State Management & Data Fetching
In SSR, if a page needs to initiate an API request to fetch data, we cannot let the client re-initiate the request (which would defeat the purpose of SSR). We need to prefetch data on the server, and then send the data to the client along with the HTML.
Process:
- The server requests data at the component level.
- After the data request is complete, the server uses a state management tool (like
Pinia) to save these states. - The server serializes the Pinia state into an inline script (e.g.,
window.__INITIAL_STATE__ = {...}) and embeds it intoindex.html. - Before initializing the app, the client directly reads data from
window.__INITIAL_STATE__and injects it into the client's Pinia (this process is called Dehydration & Hydration).
3. Cross-Request State Pollution
In traditional client-side (CSR) development, we are used to defining singleton variables at the top of the file:
// ❌ This is a disaster in SSR!
const state = reactive({ count: 0 })
export default {
/* ... */
}Reason: The Node.js server is a long-running process. If two different users access it at the same time, they will share this global state, causing data pollution and security leaks!
Correct Practice: A brand new Vue instance, Router instance, and Pinia instance must be created for each request. This is why in the handwritten code earlier, main.js provides a createApp() factory function instead of directly exporting an app instance.
// ✅ Create an independent scope for each request
export function createServerApp() {
const app = createSSRApp(App)
const router = createRouter()
const pinia = createPinia() // Always a new Pinia instance
app.use(router)
app.use(pinia)
return { app, router, pinia }
}