Most Vue apps don't need custom SSR. Nuxt handles it better. But if you're building a highly specific app where framework conventions create more friction than value, Vite's SSR primitives let you build exactly what you need.
Vite provides built-in SSR support as a low-level API "meant for library and framework authors." Use it when:
Don't use it for typical marketing sites, blogs, or e-commerce. Nuxt gives you SSR plus routing, data fetching, and SEO tooling. Custom Vite SSR gives you none of that—you build it all.
The Vue.js docs are explicit: "If you prefer a higher-level solution that provides a smooth out-of-the-box experience, you should probably give Nuxt.js a try."
Vite SSR requires three entry points:
├── index.html
├── server.js # Node server
├── src/
│ ├── main.ts # Shared app factory
│ ├── entry-client.ts # Client hydration
│ └── entry-server.ts # SSR rendering
// src/main.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
return { app }
}
Use createSSRApp() instead of createApp() for SSR compatibility. This configures Vue for server rendering and client hydration.
// src/entry-server.ts
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
export async function render(url: string) {
const { app } = createApp()
const html = await renderToString(app)
return { html }
}
renderToString() generates HTML from your Vue app. Google receives this HTML immediately—no JavaScript execution required.
// src/entry-client.ts
import { createApp } from './main'
const { app } = createApp()
app.mount('#app')
Hydrates the server-rendered HTML. The DOM structure must match exactly or you'll get hydration errors (which break SEO by causing layout shifts).
// server.js
import express from 'express'
import { createServer as createViteServer } from 'vite'
const app = express()
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom'
})
app.use(vite.middlewares)
app.get('*', async (req, res) => {
const { render } = await vite.ssrLoadModule('/src/entry-server.ts')
const { html } = await render(req.url)
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="app">${html}</div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
`)
})
app.listen(3000)
// server.ts
import { Hono } from 'hono'
import { createServer as createViteServer } from 'vite'
const app = new Hono()
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom'
})
app.use('*', async (c) => {
const { render } = await vite.ssrLoadModule('/src/entry-server.ts')
const { html } = await render(c.req.url)
return c.html(`
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="app">${html}</div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
`)
})
export default app
middlewareMode: true lets your server handle routing. Vite only handles module transformation and HMR.
Default Vue apps render meta tags client-side. Google might not wait for JavaScript.
Install Unhead:
npm install @unhead/vue
Setup requires server context:
import { renderSSRHead } from '@unhead/ssr'
// src/entry-server.ts
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
export async function render(url: string) {
const { app, head } = createApp()
const html = await renderToString(app)
const { headTags } = await renderSSRHead(head)
return { html, headTags }
}
import { createHead } from '@unhead/vue'
// src/main.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const head = createHead()
app.use(head)
return { app, head }
}
Now meta tags render in the initial HTML:
<script setup lang="ts">
import { useSeoMeta } from '@unhead/vue'
useSeoMeta({
title: 'My Page',
description: 'Renders server-side'
})
</script>
Vite doesn't include routing. Add Vue Router yourself:
// src/router.ts
import { createMemoryHistory, createRouter, createWebHistory } from 'vue-router'
export function createAppRouter(isServer: boolean) {
return createRouter({
history: isServer ? createMemoryHistory() : createWebHistory(),
routes: [
{ path: '/', component: () => import('./pages/Home.vue') },
{ path: '/about', component: () => import('./pages/About.vue') }
]
})
}
Server uses createMemoryHistory() (no URL manipulation). Client uses createWebHistory() (browser history API).
Update app factory:
import { createHead } from '@unhead/vue'
// src/main.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createAppRouter } from './router'
export function createApp(isServer = false) {
const app = createSSRApp(App)
const head = createHead()
const router = createAppRouter(isServer)
app.use(head)
app.use(router)
return { app, head, router }
}
Server entry must wait for routing:
import { renderSSRHead } from '@unhead/ssr'
// src/entry-server.ts
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
export async function render(url: string) {
const { app, head, router } = createApp(true)
await router.push(url)
await router.isReady()
const html = await renderToString(app)
const { headTags } = await renderSSRHead(head)
return { html, headTags }
}
router.isReady() waits for async components to load. Without this, Google sees empty content.
No built-in data fetching like Nuxt's useFetch(). Options:
Option A: Route-level data loading
<script setup lang="ts">
import { onServerPrefetch, ref } from 'vue'
const posts = ref([])
async function loadPosts() {
const res = await fetch('https://api.example.com/posts')
posts.value = await res.json()
}
onServerPrefetch(async () => {
await loadPosts()
})
</script>
onServerPrefetch() runs during SSR, not on client. You need to fetch again client-side or serialize state.
Option B: State serialization
// src/entry-server.ts
export async function render(url: string) {
const { app, head, router } = createApp(true)
await router.push(url)
await router.isReady()
// Data loaded during SSR is available here
const state = { /* extract state */ }
const html = await renderToString(app)
const { headTags } = await renderSSRHead(head)
return { html, headTags, state }
}
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(state)}
</script>
Client rehydrates from window.__INITIAL_STATE__. This prevents duplicate fetches but requires manual state management.
Nuxt handles this automatically. Custom Vite SSR doesn't.
Writing custom SSR is tedious. Plugins reduce boilerplate.
vite-plugin-ssr is "like Next.js/Nuxt but as do-one-thing-do-it-well Vite plugin."
npm install vite-plugin-ssr
import vue from '@vitejs/plugin-vue'
// vite.config.ts
import { defineConfig } from 'vite'
import ssr from 'vite-plugin-ssr/plugin'
export default defineConfig({
plugins: [vue(), ssr()]
})
File-based routing with .page.vue convention:
pages/
index.page.vue # /
about.page.vue # /about
blog/
index.page.vue # /blog
[slug].page.vue # /blog/:slug
Data fetching per page:
// blog/[slug].page.server.ts
export async function onBeforeRender(pageContext) {
const { slug } = pageContext.routeParams
const post = await fetch(`https://api.example.com/posts/${slug}`)
return {
pageContext: {
post: await post.json()
}
}
}
Server-only code. Never sent to client. Better for SEO (less JavaScript).
vite-ssr integrates with Vue Router and state management:
npm install vite-ssr
// src/main.ts
import { viteSSR } from 'vite-ssr/vue'
import App from './App.vue'
import routes from './routes'
export default viteSSR(App, { routes }, ({ app, router, initialState }) => {
// Setup runs once on server, once on client
// initialState syncs automatically
})
Handles state serialization and hydration. Supports Pinia out of the box.
Deploy to serverless (Vercel, Netlify) or edge workers (Cloudflare).
Development uses middleware mode. Production needs separate client and server builds.
{
"scripts": {
"dev": "node server.js",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server"
}
}
import vue from '@vitejs/plugin-vue'
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
input: {
main: './index.html'
}
}
}
})
Production server imports from server:
// server.prod.js
import express from 'express'
import { render } from './server/entry-server.js'
const app = express()
app.use(express.static('dist/client'))
app.get('*', async (req, res) => {
const { html, headTags } = await render(req.url)
res.send(`
<!DOCTYPE html>
<html>
<head>
${headTags}
</head>
<body>
<div id="app">${html}</div>
<script type="module" src="/assets/entry-client.js"></script>
</body>
</html>
`)
})
app.listen(3000)
Custom Vite SSR gives you:
You lose:
The Vue SSR guide warns: "More involved build setup and deployment requirements. Unlike a fully static SPA that can be deployed on any static file server, a server-rendered app requires an environment where a Node.js server can run."
If you're building a typical web app, use Nuxt. If you need SSR for a specific use case (embedded widgets, multi-tenant platforms, legacy integration), Vite's primitives let you build exactly what you need.
Using lifecycle hooks incorrectly
onMounted() never runs on server. onServerPrefetch() never runs on client. Use the right hook for each environment.
Hydration mismatches
Server HTML must match client exactly. Random IDs, timestamps, or client-only rendering breaks hydration:
❌ Bad
<template>
<div :id="`item-${Math.random()}`">
Content
</div>
</template>
✅ Good
<template>
<div :id="`item-${props.id}`">Content</div>
</template>
Accessing browser APIs during SSR
window, document, localStorage don't exist on server:
// ❌ Bad
const saved = localStorage.getItem('theme')
// ✅ Good
const saved = import.meta.env.SSR
? null
: localStorage.getItem('theme')
Or use onMounted() which only runs client-side.
Not testing the production build
Vite's dev server behaves differently than production. Always test npm run build && node server.prod.js before deploying.
Test SSR output before deploying:
curl http://localhost:3000/blog/my-post
Should return full HTML with content. If you see <div id="app"></div> with no content, SSR isn't working.
Use Google's URL Inspection tool to verify Googlebot sees rendered content.
Nuxt handles all of this automatically with file-based routing, built-in data fetching, and SEO utilities. Check out Nuxt SEO for production-ready SEO tooling.