Custom Vite SSR for Vue: When to Roll Your Own

Building server-side rendering with Vite instead of frameworks. When custom SSR makes sense and SEO considerations.
Harlan WiltonHarlan Wilton Published
What you'll learn
  • 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

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

├── 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

// 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

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

Client Entry

// 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

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

middlewareMode: true lets your server handle routing. Vite 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:

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>

2. Routing Requires Manual Setup

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.

3. Data Fetching Needs Coordination

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

Option A: Route-level data loading

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

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

Using Vite SSR Plugins

Writing custom SSR is tedious. Plugins reduce boilerplate.

vite-plugin-ssr

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 (frandiox)

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).

Production Build

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)

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: "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.

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:

❌ 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.

Verification

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.

Using Nuxt?

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.

Learn more about Nuxt →