---
title: "How to Prerender a Vue SPA for SEO"
description: "Generate static HTML for your Vue SPA with vite-ssg so crawlers see real content instead of an empty div, with a static hosting target."
canonical_url: "https://nuxtseo.com/learn-seo/vue/spa/prerendering"
last_updated: "2026-10-05"
---

::key-takeaways
- Prerendering renders your routes to static HTML at build time, so crawlers get real content instead of an empty `<div id="app">`{lang="html"}
- Use `vite-ssg` for new projects: prerender-spa-plugin and Rendertron are both archived and unmaintained
- Prerendering only fits routes known ahead of time; rebuild as content changes; use request-time rendering when page output must depend on a request
- Browser speculation rules are a separate optional optimization for full document navigations, with limited browser support
::

Build-time prerendering creates HTML before deployment. The mechanism depends on the tool: `vite-ssg` uses Vue server rendering, while legacy browser-based tools load pages in a headless browser.

The resulting files can contain route-specific content and metadata before JavaScript runs. Google can also render client-side JavaScript; static output does not guarantee indexing or bypass Google's rendering queue.

## When to Prerender

Use prerendering when:

- You have a client-side SPA (no SSR framework)
- Routes are known at build time
- Your rebuild and deployment schedule meets content freshness needs
- You want to publish static files without a request-time page renderer

Don't prerender when:

- Required freshness cannot be met by your rebuild process
- Initial page output must contain private or request-specific data
- The measured build cost exceeds your deployment budget

Public pages can be rebuilt when their data changes. For request-specific output, compare [SSR](/learn-seo/vue/routes-and-rendering/rendering) with client data loading. Never publish private user data into shared static files.

## Modern Prefetching: Speculation Rules API

The [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API) can prefetch or prerender future document navigations in supporting browsers. It targets full page URLs, not ordinary Vue Router transitions within the current document.

Treat it separately from build-time generation. Check browser compatibility and feature detection. Choose safe read-only destinations and measure resource use before enabling speculative loading. It does not guarantee an INP improvement.

## Build-Time vs On-Demand Prerendering

**Build-time prerendering** (vite-ssg):

- Generates HTML through the configured `vite-ssg build` command
- Deploy as static files to CDN
- Serves static output, with hosting and delivery costs
- Requires a rebuild to change generated page content

**On-demand prerendering** (hosted render services):

- Service prerenders when crawler visits
- Detects bots by user agent
- Freshness depends on the service's cache and rendering rules
- Adds a service, cache policy, and billing model to operate

Choose by route inventory, freshness, and deployment needs. A crawler-specific hosted renderer is dynamic rendering, with a separate maintenance cost.

## Build-Time: vite-ssg (Recommended)

[vite-ssg](https://github.com/antfu/vite-ssg) prerenders Vue apps using Vite's SSR capabilities. It's the modern replacement for `prerender-spa-plugin`.

This example starts with an installed Vue/Vite project and its `index.html` entry. It was checked with Node 24.18.0, [Vite](https://vite.dev) 8.3.0, Vue 3.5.43, Vue Router 4.6.4, and vite-ssg 28.3.0. This vite-ssg release uses Unhead 2, so install compatible dependencies:

```bash
pnpm add vue-router@4.6.4 @unhead/vue@2.1.2
pnpm add -D vite-ssg@28.3.0
```

Change the existing package build script to `vite-ssg build`. A plain `vite build` creates the client bundle and does not generate these page contents.

```json [package.json excerpt]
{
  "scripts": {
    "build": "vite-ssg build"
  }
}
```

Use two declared routes and page components:

::code-group
```ts [src/main.ts]
import { ViteSSG } from 'vite-ssg'
import App from './App.vue'
import routes from './routes'

export const createApp = ViteSSG(App, { routes }, undefined, { hydration: true })
```

```ts [src/routes.ts]
import About from './pages/About.vue'
import Home from './pages/Home.vue'

export default [{ path: '/', component: Home }, { path: '/about', component: About }]
```

```vue [src/App.vue]
<script setup lang="ts">
/* eslint-disable harlanzw/nuxt-prefer-nuxt-link-over-router-link -- Plain Vue example uses Vue Router. */
</script>

<template>
  <nav>
    <RouterLink to="/">
      Home
    </RouterLink>
    <RouterLink to="/about">
      About
    </RouterLink>
  </nav>
  <RouterView />
</template>
```

```vue [src/pages/Home.vue]
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { ref } from 'vue'

const count = ref(0)
useHead({ title: 'Home', meta: [{ name: 'description', content: 'Home description' }] })
</script>

<template>
  <main>
    <h1>Home</h1><button @click="count++">
      Count {{ count }}
    </button>
  </main>
</template>
```

```vue [src/pages/About.vue]
<script setup lang="ts">
import { useHead } from '@unhead/vue'

useHead({ title: 'About', meta: [{ name: 'description', content: 'About description' }] })
</script>

<template>
  <main><h1>About</h1><p>About this site.</p></main>
</template>
```

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

export default defineConfig({ plugins: [vue()], ssgOptions: { dirStyle: 'nested', formatting: 'minify' } })
```
::

Build the project:

```bash
pnpm build
```

With `dirStyle: 'nested'`, the example emits `dist/index.html` and `dist/about/index.html`. Inspect both files for the page heading, title, and description. Deploy `dist/` to a static host with routing that serves those files. This example explicitly enables hydration. Client-side navigation still needs the generated client assets.

For additional routes, declare or generate their paths before the build. Route count alone does not define a universal build limit.

## Build-Time: prerender-spa-plugin (Legacy)

[prerender-spa-plugin](https://github.com/chrisvfritz/prerender-spa-plugin) is archived. Its browser-based renderer belongs to older webpack/Vue CLI workflows. Avoid copying that configuration into a Vite project. Use a maintained tool that matches your build pipeline and verify its output.

## On-Demand: Hosted Render Services

An on-demand render service detects crawler requests by user agent, renders the page with a headless browser on the fly, and serves the cached HTML to bots while users still get the client-rendered SPA. That's [dynamic rendering](/learn-seo/vue/spa/dynamic-rendering) sold as a managed product. Google calls dynamic rendering [a workaround and not a long-term solution](https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering): Google generally does not treat similar bot and human content as cloaking. A working SSR or static-generation replacement can remove the separate crawler response path.

Evaluate any existing render service against its documented routing, caching, and content-equivalence behavior. A generic proxy URL and API-key placeholder are not a complete deployment. Prefer migration toward SSR or static generation when those meet your app's needs.

[Rendertron](https://github.com/GoogleChrome/rendertron) is archived. Do not build a new dependency on its old hosted endpoint.

## Detecting Prerendered Environment

Vue server rendering does not run `onMounted()`{lang="ts"}. Keep browser-only effects there and avoid browser-dependent imports in the server bundle. A legacy browser-based prerenderer has different execution behavior; do not mix its environment flags with vite-ssg.

Check that data and initial markup match between generated HTML and client hydration. Suppressing a warning with [`data-allow-mismatch`](https://vuejs.org/api/ssr.html#data-allow-mismatch) does not make missing data correct.

## Dynamic Routes

Use vite-ssg's documented [`includedRoutes` hook](https://github.com/antfu/vite-ssg#custom-routes-to-render) to supply concrete paths for parameterized routes. The returned paths must match declared route records, and their page data must be available during generation.

Build a representative route set and measure its time and output. Compare full rebuilds, supported incremental workflows, and request-time rendering when the measured cost or freshness requirements justify a change.

## Testing Prerendered Output

After building, verify the generated files and the deployed responses:

**1. Check static HTML**

```bash
pnpm build
cat dist/about/index.html
```

Should contain your actual content, not just `<div id="app"></div>`{lang="html"}.

**2. [Google Search Console URL Inspection](https://search.google.com/search-console/)**

- Test live URL
- View rendered HTML
- Check screenshot

**3. Fetch as Googlebot**

```bash
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://yoursite.com/about
```

If generated content is missing from the response, check build output and host routing. A Googlebot user-agent string does not reproduce Google's renderer or verify the caller.

## Common Mistakes

**Mistake 1: Blocking JavaScript in robots.txt**

```robots-txt
# Can block Google from rendering required app resources
User-agent: *
Disallow: /*.js$
```

[Google cannot render blocked resources](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics). Keep scripts and styles needed for public page rendering crawlable. See our [robots.txt guide](/learn-seo/vue/controlling-crawlers/robots-txt).

**Mistake 2: Prerendering user-specific content**

Prerendering generates static HTML. Use request-time rendering or authenticated client requests for private data. Host access control can protect private static files, but a shared public build cannot personalize its initial content per user.

**Mistake 3: Not handling async data**

Vue server rendering can await supported async work. Load page data through the generation pipeline and transfer the same initial state for hydration. Do not use a legacy `renderAfterDocumentEvent` browser signal as a vite-ssg data-loading API. Follow its [initial-state and async-component guidance](https://github.com/antfu/vite-ssg#initial-state), then check that the actual data appears in generated HTML.

**Mistake 4: Large route counts**

Route count, data latency, component work, and concurrency determine build cost. Measure your workload before choosing a route limit or another renderer. Every published route still needs complete data and correct metadata.

## Comparison Table

| Approach                  | Rendering mechanism                                                     | Constraint                                         |
| ------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |
| **vite-ssg**              | Vue SSR at build time                                                   | Known paths, build-compatible code and data        |
| **prerender-spa-plugin**  | Headless browser in a legacy [webpack](https://webpack.js.org) pipeline | Archived project                                   |
| **Hosted render service** | Separate crawler response path                                          | Vendor routing, cache, and infrastructure behavior |
| **Rendertron**            | Headless browser service                                                | Archived project                                   |

Nuxt can prerender configured routes with its generate command. Hybrid server rendering and CDN ISR require a compatible server deployment and provider support. [Learn more about Nuxt prerendering →](/learn-seo/nuxt/routes-and-rendering/rendering)

## Checklist

::checklist{#vue-prerendering}
- Prerender build-time routes with `vite-ssg`, not prerender-spa-plugin or Rendertron
- Confirm the raw HTML contains your content (`pnpm build`, then check the output files)
- Confirm robots.txt doesn't block `.js` or `.css` from Googlebot
- Treat speculation rules as optional document-navigation behavior, with compatible browser checks
- Keep private data out of public build output; measure build cost and freshness needs before choosing another renderer
::

## Sitemap

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