How to Prerender a Vue SPA for SEO
- Prerendering renders your routes to static HTML at build time, so crawlers get real content instead of an empty
<div id="app"> - Use
vite-ssgfor 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 with client data loading. Never publish private user data into shared static files.
Modern Prefetching: Speculation Rules API
The 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 buildcommand - 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 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 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:
pnpm add vue-router@4.6.4 @unhead/vue@2.1.2
pnpm add -D vite-ssg@28.3.0Change the existing package build script to vite-ssg build. A plain vite build creates the client bundle and does not generate these page contents.
{
"scripts": {
"build": "vite-ssg build"
}
}Use two declared routes and page components:
import { ViteSSG } from 'vite-ssg'
import App from './App.vue'
import routes from './routes'
export const createApp = ViteSSG(App, { routes }, undefined, { hydration: true })import About from './pages/About.vue'
import Home from './pages/Home.vue'
export default [{ path: '/', component: Home }, { path: '/about', component: About }]<template>
<nav>
<RouterLink to="/">
Home
</RouterLink>
<RouterLink to="/about">
About
</RouterLink>
</nav>
<RouterView />
</template><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><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>import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({ plugins: [vue()], ssgOptions: { dirStyle: 'nested', formatting: 'minify' } })Build the project:
pnpm buildWith 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 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 sold as a managed product. Google calls dynamic rendering a workaround and not a long-term solution: 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 is archived. Do not build a new dependency on its old hosted endpoint.
Detecting Prerendered Environment
Vue server rendering does not run onMounted(). 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 does not make missing data correct.
Dynamic Routes
Use vite-ssg's documented includedRoutes hook 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
pnpm build
cat dist/about/index.htmlShould contain your actual content, not just <div id="app"></div>.
2. Google Search Console URL Inspection
- Test live URL
- View rendered HTML
- Check screenshot
3. Fetch as Googlebot
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://yoursite.com/aboutIf 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
# Can block Google from rendering required app resources
User-agent: *
Disallow: /*.js$Google cannot render blocked resources. Keep scripts and styles needed for public page rendering crawlable. See our robots.txt guide.
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, 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 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 →
Checklist
- 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
.jsor.cssfrom 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
Pre-launch setup, post-launch verification, and ongoing monitoring. Interactive checklist with links to every guide.