---
title: "Custom Vite SSR for Vue: When to Roll Your Own"
description: "Building server-side rendering with Vite instead of frameworks. When custom SSR makes sense and SEO considerations."
canonical_url: "https://nuxtseo.com/learn-seo/vue/ssr-frameworks/vite-ssr"
last_updated: "2026-05-21T18:10:02.472Z"
---

<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 is for specific use cases (microservices, legacy integration)
- Remember: meta tags must render server-side, and hydration mismatches break SEO

</key-takeaways>

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.

## 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 explicit](https://vuejs.org/guide/scaling-up/ssr.html): "If you prefer a higher-level solution that provides a smooth out-of-the-box experience, you should probably give Nuxt.js a try."

## Basic Vite SSR Setup

Vite SSR requires three entry points:

```text
├── index.html
├── server.js          # Node server
├── src/
│   ├── main.ts        # Shared app factory
│   ├── entry-client.ts # Client hydration
│   └── entry-server.ts # SSR rendering
```

### Shared App Factory

```ts
// 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.

### Server Entry

```ts
// 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()`](https://vuejs.org/guide/scaling-up/ssr.html) generates HTML from your Vue app. Google receives this HTML immediately. no JavaScript execution required.

### Client Entry

```ts
// 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 Setup

<code-group>

```ts [Express]
// 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)
```

```ts [Hono]
// 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
```

</code-group>

`middlewareMode: true` lets your server handle routing. [Vite](https://vite.dev) only handles module transformation and HMR.

## SEO Considerations for Custom SSR

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

Default Vue apps render meta tags client-side. Google might not wait for JavaScript.

Install [Unhead](https://unhead.unjs.io/):

```bash
npm install @unhead/vue
```

Setup requires server context:

```ts
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 }
}
```

```ts
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:

```vue
<script setup lang="ts">
import { useSeoMeta } from '@unhead/vue'

useSeoMeta({
  title: 'My Page',
  description: 'Renders server-side'
})
</script>
```

### 2. Routing Requires Manual Setup

Vite doesn't include routing. Add Vue Router yourself:

```ts
// 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:

```ts
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:

```ts
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.

### 3. Data Fetching Needs Coordination

No built-in data fetching like Nuxt's `useFetch()`. Options:

**Option A: Route-level data loading**

```vue [src/pages/Blog.vue]
<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**

```ts
// 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 }
}
```

```html
<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](https://nuxt.com/docs/getting-started/data-fetching). Custom Vite SSR doesn't.

## Using Vite SSR Plugins

Writing custom SSR is tedious. Plugins reduce boilerplate.

### Vike

[Vike](https://vike.dev/) (formerly vite-plugin-ssr) is "like Next.js/Nuxt but as do-one-thing-do-it-well Vite plugin."

```bash
npm install vike vike-vue
```

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

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

File-based routing with `+Page.vue` convention:

```text
pages/
  index/+Page.vue      # /
  about/+Page.vue      # /about
  blog/
    index/+Page.vue    # /blog
    @slug/+Page.vue    # /blog/:slug
```

Data fetching per page:

```ts
// blog/@slug/+data.ts
export async function data(pageContext) {
  const { slug } = pageContext.routeParams
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  return { post: await res.json() }
}
```

Server-only code. Never sent to client. Better for SEO (less JavaScript).

### vite-ssr (frandiox)

[vite-ssr](https://github.com/frandiox/vite-ssr) integrates with Vue Router and state management:

```bash
npm install vite-ssr
```

```ts
// 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](https://pinia.vuejs.org) by default.

Deploy to serverless (Vercel, [Netlify](https://netlify.com)) or edge workers (Cloudflare).

## Production Build

Development uses middleware mode. Production needs separate client and server builds.

```json
{
  "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"
  }
}
```

```ts
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`:

```ts
// 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)
```

## Trade-offs vs Frameworks

**Custom Vite SSR gives you:**

- Full architectural control
- Minimal bundle size (no framework overhead)
- Custom rendering pipeline (streaming, partial hydration)
- Integration flexibility

**You lose:**

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

[The Vue SSR guide warns](https://vuejs.org/guide/scaling-up/ssr.html): "More involved build setup and deployment requirements. Unlike a fully static SPA that you can deploy on any static file server, a server-rendered app requires an environment where a [Node.js](https://nodejs.org) 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.

## Common Mistakes

**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:

```vue
❌ Bad
<script setup lang="ts">
const id = import.meta.client ? Math.random() : 0.5
</script>

<template>
  <div :id="`item-${id}`">
    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:

```ts
// ❌ 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.

## Verification

Test SSR output before deploying:

```bash
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](https://search.google.com/search-console) to verify Googlebot sees rendered content.

## Using Nuxt?

Nuxt handles all of this automatically with file-based routing, built-in data fetching, and SEO utilities. 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)
