---
title: "Custom Vite SSR for Vue: When to Roll Your Own"
description: "Build a minimal Vue SSR app with Vite and Express. Render route content and metadata, then check development and production responses."
canonical_url: "https://nuxtseo.com/learn-seo/vue/ssr-frameworks/vite-ssr"
last_updated: "2026-10-05"
---

::key-takeaways
- Custom Vite SSR gives full control but requires building routing, data fetching, and SEO tooling yourself
- Most apps should use Nuxt; custom SSR fits specific cases like microservices, multi-tenant apps, or legacy integration
- Put content and metadata in initial HTML. Hydration mismatches can affect rendering and user experience
::

For a typical Vue application, a framework can reduce the SSR integration work. If framework conventions create more friction than value for your app, Vite's SSR primitives let you build your own integration.

## When Custom Vite SSR Makes Sense

[Vite provides built-in SSR support](https://vite.dev/guide/ssr) as a low-level API "meant for library and framework authors." Use it when:

- You're building a framework or library yourself
- Framework conventions conflict with your architecture (microservices, multi-tenant apps, legacy integration)
- You need full control over the SSR pipeline for performance optimization
- Your app is small enough that framework overhead isn't worth it

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 direct about it](https://vuejs.org/guide/scaling-up/ssr.html): "we highly recommend using Vue frameworks if you need SSR since they often have built-in SSR support."

## Basic Vite SSR Setup

This minimal example uses Node 24.18.0, Vue 3.5.43, Vue Router 4.6.4, Unhead 3.4.2, [Vite](https://vite.dev) 8.3.0, and Express 5.2.1. Run commands from the application root. It covers two static routes, without authentication or data fetching.

```bash
pnpm add vue@3.5.43 vue-router@4.6.4 @unhead/vue@3.4.2 express@5.2.1
pnpm add -D vite@8.3.0 @vitejs/plugin-vue@6.0.9
```

Set `"type": "module"` in package.json. Create the HTML placeholder, Vue plugin configuration, and root component:

```html [index.html]
<!doctype html><html><head><meta charset="UTF-8"></head><body>
<div id="app"><!--app-html--></div><script type="module" src="/src/entry-client.ts"></script>
</body></html>
```

```ts [vite.config.ts]
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'

export default defineConfig({ plugins: [vue()] })
```

```vue [src/App.vue]
<script setup lang="ts">
import { useHead, useSeoMeta } from '@unhead/vue'
import { RouterView, useRoute } from 'vue-router'

const route = useRoute()
useSeoMeta({ title: () => String(route.meta.title || 'My App') })
useHead({ link: [{ rel: 'canonical', href: () => new URL(route.path, 'https://mysite.com').href }] })
</script>

<template>
  <RouterView />
</template>
```

### Shared App Factory

```ts [src/main.ts]
import { createSSRApp, h } from 'vue'
import { createMemoryHistory, createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'

export function createApp(isServer: boolean) {
  const app = createSSRApp(App)
  const router = createRouter({
    history: isServer ? createMemoryHistory() : createWebHistory(),
    routes: [
      { path: '/', component: { render: () => h('h1', 'Home') }, meta: { title: 'Home' } },
      { path: '/about', component: { render: () => h('h1', 'About Us') }, meta: { title: 'About Us' } }
    ]
  })
  app.use(router)
  return { app, router }
}
```

Create a fresh app and router for each SSR request. Memory history avoids browser APIs on the server.

### Server Entry

```ts [src/entry-server.ts]
import { createHead } from '@unhead/vue/server'
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'

export async function render(url: string) {
  const { app, router } = createApp(true)
  const head = createHead()
  app.use(head)
  await router.push(url)
  await router.isReady()
  const html = await renderToString(app)
  return { html, head }
}
```

Resolve the requested route before rendering. Install a fresh server head instance on that request's app.

### Client Entry

```ts [src/entry-client.ts]
import { createHead } from '@unhead/vue/client'
import { createApp } from './main'

const { app, router } = createApp(false)
app.use(createHead())
await router.isReady()
app.mount('#app')
```

Install the client head instance, wait for routing, then hydrate. Initial client content must match the server content.

### Server Setup

```ts [server.ts]
import { readFile } from 'node:fs/promises'
import { transformHtmlTemplate } from '@unhead/vue/server'
import express from 'express'
import { createServer as createViteServer } from 'vite'

const app = express()
const vite = await createViteServer({ server: { middlewareMode: true, watch: null, hmr: false }, appType: 'custom' })
app.use(vite.middlewares)
app.get('/{*splat}', async (req, res) => {
  const template = await vite.transformIndexHtml(req.originalUrl, await readFile('index.html', 'utf8'))
  const { render } = await vite.ssrLoadModule('/src/entry-server.ts')
  const rendered = await render(req.originalUrl)
  const html = await transformHtmlTemplate(rendered.head, template.replace('<!--app-html-->', rendered.html))
  res.type('html').send(html)
})
const server = app.listen(Number(process.env.PORT || 3000), '127.0.0.1', () => {
  const address = server.address()
  if (address && typeof address !== 'string')
    console.log(`PORT=${address.port}`)
})
```

Run `node server.ts`. This development example disables file watching and HMR. Enable them according to your development needs. Vite transforms the HTML template and serves client modules. Express 5 requires a named wildcard; `/{*splat}` includes the root route. See [Vite's SSR integration guide](https://vite.dev/guide/ssr).

## SEO Considerations for Custom SSR

### 1. Meta Tags Must Render Server-Side

The example puts title and canonical tags in initial HTML. Google can also render JavaScript, but resources or rendering can fail. Initial HTML simplifies checks and helps clients that do not render page scripts.

App.vue calls `useSeoMeta` and `useHead`. The server entry installs `createHead` from `@unhead/vue/server`. The server inserts its tags using `transformHtmlTemplate`. The client uses `@unhead/vue/client` for navigation updates. Do not share a server head instance across requests.

### 2. Routing Requires Manual Setup

Vite does not include Vue Router. The shared factory above creates memory history on the server and web history on the client. Add your application's routes there.

The server pushes the request URL and waits for `router.isReady()`{lang="ts"} before `renderToString()`{lang="ts"}. The client also waits before mounting. This matters when route components load asynchronously. Configure response status handling for missing routes before expanding this minimal example.

### 3. Data Fetching Needs Coordination

The static example has no data loader. If components fetch during `onServerPrefetch`, Vue waits for those promises while rendering. Parse API responses at the boundary and pass typed data into components.

If you serialize server state, extract it after rendering completes. Use a serializer designed for embedding untrusted data in HTML. Raw `JSON.stringify` inside a script can expose script-injection risks. Transfer the same state to the client before hydration. Follow [Vue's SSR data guidance](https://vuejs.org/guide/scaling-up/ssr.html) or a framework's supported data loader.

## Using Vite SSR Plugins

Writing custom SSR is tedious. Plugins reduce boilerplate.

### Vike

[Vike](https://vike.dev/vue) provides a Vue integration with routing and data-loading conventions. Follow its current Vue setup. Its configuration differs from this hand-written Express example.

### vite-ssr (frandiox)

[vite-ssr](https://www.npmjs.com/package/vite-ssr) is another integration package. Check its released documentation and compatibility with your installed Vite and Vue versions before choosing it. Do not assume the current Vite APIs work with an older integration.

## Production Build

Build the client and server separately. Run these from the application root:

```bash
pnpm exec vite build --outDir dist/client
pnpm exec vite build --ssr src/entry-server.ts --outDir dist/server
```

Serve built assets without letting static middleware serve index.html before SSR:

```ts [server.prod.ts]
import { readFile } from 'node:fs/promises'
import { transformHtmlTemplate } from '@unhead/vue/server'
import express from 'express'
// eslint-disable-next-line antfu/no-import-dist -- The production server loads its explicitly built SSR artifact.
import { render } from './dist/server/entry-server.js'

const app = express()
const template = await readFile('dist/client/index.html', 'utf8')
app.use(express.static('dist/client', { index: false }))
app.get('/{*splat}', async (req, res) => {
  const rendered = await render(req.originalUrl)
  const html = await transformHtmlTemplate(rendered.head, template.replace('<!--app-html-->', rendered.html))
  res.type('html').send(html)
})
const server = app.listen(Number(process.env.PORT || 3000), '127.0.0.1', () => {
  const address = server.address()
  if (address && typeof address !== 'string')
    console.log(`PORT=${address.port}`)
})
```

Run `node server.prod.ts`. The import points to the server build output. The template comes from the client build. `index: false` ensures the root route also passes through SSR.

## Trade-offs vs Frameworks

**Custom Vite SSR gives you:**

- Full architectural control
- Control over dependencies and bundle composition
- A rendering pipeline you maintain and test
- Integration flexibility

**You lose:**

- File-based routing (manual setup)
- Data fetching utilities (manual state management)
- SEO tooling (no automatic sitemaps, meta management, [schema.org](http://schema.org))
- Deployment presets (manual server configuration)
- Developer experience (no conventions)

[The Vue SSR guide warns](https://vuejs.org/guide/scaling-up/ssr.html) that a fully static SPA can run on any static file server, while a server-rendered app needs an environment that can run a [Node.js](https://nodejs.org) server.

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.

## Common Mistakes

**Using lifecycle hooks incorrectly**

`onMounted()`{lang="ts"} never runs on server. `onServerPrefetch()`{lang="ts"} never runs on client. Use the right hook for each environment.

**Hydration mismatches**

Server HTML must match client exactly. Different random IDs, timestamps, or client-only branches can cause a hydration mismatch. Pass the same initial values to server and client:

```vue
<script setup lang="ts">
const props = defineProps<{ id: number }>()
</script>

<template>
  <div :id="`item-${props.id}`">
    Content
  </div>
</template>
```

**Accessing browser APIs during SSR**

`window`, `document`, `localStorage` don't exist on server:

```ts
// ❌ Bad
const saved = localStorage.getItem('theme')

// ✅ Good
const saved = import.meta.env.SSR
  ? null
  : localStorage.getItem('theme')
```

Or use `onMounted()`{lang="ts"} which only runs client-side.

**Not testing the production build**

Vite's dev server behaves differently than production. Build both outputs and test `node server.prod.ts` before deploying.

## Verification

Test SSR output before deploying:

```bash
curl http://localhost:3000/
curl http://localhost:3000/about
```

For this example, both responses should return 200 with their own h1, title, and canonical URL. Check the built client asset returns 200 too. This verifies HTTP output, not browser hydration. Test client navigation and hydration separately.

Use [Google's URL Inspection tool](https://search.google.com/search-console) to verify Googlebot sees rendered content.

Using Nuxt? It provides file-based routing and data-fetching APIs. Configure the SEO modules your application needs. Check out [Nuxt SEO](/docs/nuxt-seo/getting-started/introduction) for production-ready SEO tooling. [Learn more about Nuxt →](/learn-seo/nuxt/routes-and-rendering/rendering)

## Checklist

::checklist{#vite-ssr-vue}
- Meta tags render server-side via `useSeoMeta()`{lang="ts"} and `transformHtmlTemplate()`{lang="ts"}, not client-only code
- `router.isReady()`{lang="ts"} resolves before `renderToString()`{lang="ts"} runs, so async route components are in the output
- Server and client render identical output: no random IDs, timestamps, or client-only branches in the initial render
- Production build tested with `node server.prod.ts`, not just the Vite dev server
- `curl` or "View Source" confirms content and meta tags exist in the raw HTML before hydration runs
::

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
