Prerendering Vue SPAs for SEO

Build-time and on-demand prerendering for client-side Vue apps. How to get SPA performance with SSR indexing.
Harlan WiltonHarlan Wilton12 mins read Published
What you'll learn
  • Prerendering generates HTML at build time—deploy as static files to any CDN
  • Works for static content with routes known at build time
  • Use vite-ssg for modern projects, Prerender.io for legacy SPAs with frequent updates

Prerendering gives you SPA performance with SSR indexing. You fire up a headless browser at build time, load your routes, dump the HTML. Google sees content immediately.

Google can execute JavaScript, but it's slower and less reliable than HTML. Prerendering removes the wait.

When to Prerender

Use prerendering when:

  • You have a client-side SPA (no SSR framework)
  • Routes are known at build time
  • Content changes daily or less frequently
  • You want fast deployment (static files, no server)

Don't prerender when:

  • Content changes hourly (use SSR instead)
  • Routes depend on user data (profiles, dashboards)
  • You have 10,000+ dynamic pages (builds time out)

If your /about and /pricing pages never change, prerender them. If you're showing user-generated content or real-time data, you need SSR.

Build-Time vs On-Demand Prerendering

Build-time prerendering (vite-ssg, prerender-spa-plugin):

  • Generates HTML during npm run build
  • Deploy as static files to CDN
  • Fast serving, no runtime cost
  • Can't handle frequently updated content

On-demand prerendering (Prerender.io, Rendertron):

  • Service prerenders when crawler visits
  • Detects bots by user agent
  • Serves fresh content to crawlers
  • Costs money, adds latency

Most Vue apps need build-time prerendering. On-demand is for SPAs with frequent content changes that can't use SSR.

Build-Time: vite-ssg

vite-ssg prerenders Vue apps using Vite's SSR capabilities. It's the modern replacement for prerender-spa-plugin.

Install:

npm install -D vite-ssg

Update your entry file:

import { ViteSSG } from 'vite-ssg'
import App from './App.vue'
import routes from './routes'

export const createApp = ViteSSG(
  App,
  { routes },
  ({ app, router, initialState }) => {
    // Install plugins, setup app
  }
)

Build generates HTML files matching your routes:

npm run build
# Creates dist/index.html, dist/about/index.html, etc.

Deploy dist/ to Netlify, Vercel, Cloudflare Pages—anywhere that serves static files.

Limitations:

  • Routes must be defined at build time (no /user/:id unless you generate all IDs)
  • Build time increases with route count
  • Client-side navigation still works after hydration

Build-Time: prerender-spa-plugin

prerender-spa-plugin uses Puppeteer to render pages. Works with webpack and Vue CLI, but hasn't been updated since 2019. Use vite-ssg for new projects.

If you're on Vue CLI:

npm install -D prerender-spa-plugin
const path = require('node:path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')

module.exports = {
  configureWebpack: {
    plugins: [
      new PrerenderSPAPlugin({
        staticDir: path.join(__dirname, 'dist'),
        routes: ['/', '/about', '/pricing'],
        renderer: new PrerenderSPAPlugin.PuppeteerRenderer({
          renderAfterDocumentEvent: 'render-event'
        })
      })
    ]
  }
}

Why vite-ssg wins:

  • Maintained actively (2025 updates)
  • No Puppeteer dependency (faster builds)
  • Built for Vite (modern tooling)
  • Better DX

On-Demand: Prerender.io

Prerender.io renders pages when crawlers visit. It detects bots by user agent, serves cached HTML to them, serves your SPA to humans.

Setup with Express middleware:

npm install prerender-node

Prerender.io caches rendered pages, serves them to crawlers. Your users still get fast client-side navigation.

Costs:

  • Free tier: 250 cached pages
  • Paid: Starts at $30/month for 10,000 pages

When to use:

  • Content updates hourly but you can't use SSR
  • Product catalogs with 1000+ pages
  • You need SEO but have legacy SPA codebase

Don't use if:

  • You can implement SSG (it's free)
  • Content rarely changes (build-time wins)

On-Demand: Rendertron

Rendertron is Google's open-source prerendering service. You host it yourself.

docker run -p 3000:3000 rendertron/rendertron

Configure your server to proxy bot requests:

import express from 'express'

const app = express()
const RENDERTRON_URL = 'http://localhost:3000'

app.use((req, res, next) => {
  const botPattern = /googlebot|bingbot|slackbot|twitterbot/i
  if (botPattern.test(req.headers['user-agent'])) {
    const url = `${RENDERTRON_URL}/render/${req.protocol}://${req.get('host')}${req.url}`
    // Fetch from Rendertron, return HTML
    return fetch(url).then(r => r.text()).then(html => res.send(html))
  }
  next()
})

Benefits:

  • Free (self-hosted)
  • No vendor lock-in
  • Control caching behavior

Drawbacks:

  • You manage infrastructure
  • Cache invalidation is manual
  • Adds latency on first render

Detecting Prerendered Environment

Your Vue app needs to know when it's being prerendered to avoid browser-only APIs:

import { onMounted } from 'vue'

export default {
  setup() {
    onMounted(() => {
      // Only runs in browser, not during prerender
      if (typeof window !== 'undefined') {
        initializeAnalytics()
      }
    })
  }
}

Common mistakes:

  • Calling localStorage during prerender (crashes build)
  • Fetching from http://localhost (fails in CI)
  • Forgetting data-server-rendered="true" on root element

Add to your root element so Vue hydrates instead of re-rendering:

<div id="app" data-server-rendered="true"></div>

Dynamic Routes

Prerendering dynamic routes requires generating all possible paths:

// vite.config.ts
export default {
  ssgOptions: {
    async includedRoutes(paths) {
      // Fetch all blog post slugs
      const posts = await fetch('https://api.example.com/posts')
        .then(r => r.json())

      return posts.map(p => `/blog/${p.slug}`)
    }
  }
}

For 1000+ dynamic routes, consider:

  • SSR instead (no build-time cost)
  • Incremental builds (prerender popular pages only)
  • On-demand services (Prerender.io)

Testing Prerendered Output

After building, verify Google sees content:

1. Check static HTML

npm run build
cat dist/about/index.html

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

If you see empty content, prerendering failed.

Common Mistakes

Mistake 1: Blocking JavaScript in robots.txt

# ❌ Breaks prerendering detection
User-agent: *
Disallow: /*.js$

Google needs JavaScript to hydrate your app. Never block .js or .css from Googlebot. See our robots.txt guide.

Mistake 2: Prerendering user-specific content

Prerendering generates static HTML. If your route shows different content per user, you need SSR.

Mistake 3: Not handling async data

Prerender runs before async requests complete:

// ❌ Data won't be in prerendered HTML
export default {
  async setup() {
    const data = await fetch('/api/data')
    return { data }
  }
}

Use renderAfterDocumentEvent to wait for data:

// Emit when data loads
fetch('/api/data').then((data) => {
  document.dispatchEvent(new Event('render-event'))
})

Mistake 4: Large route counts

Prerendering 10,000 routes takes hours and often fails in CI. Solutions:

  • Prerender popular pages only (use analytics data)
  • Switch to SSR
  • Use on-demand prerendering

Comparison Table

ApproachCostSetupBest For
vite-ssgFreeEasyModern Vue apps, <1000 routes
prerender-spa-pluginFreeMediumLegacy Vue CLI projects
Prerender.io$30+/moEasyFrequent updates, can't use SSR
RendertronHostingHardHigh traffic, need control

Using Nuxt?

Nuxt handles prerendering automatically with nuxi generate. It supports hybrid rendering (SSR + SSG mixed), ISR (Incremental Static Regeneration), and advanced route rules.

Learn more about Nuxt prerendering →

Quick Check

When should you use prerendering vs SSR?

  • Prerendering when content updates hourly - SSR is better for frequently updated content
  • SSR when routes are known at build time - Prerendering works well for static routes known at build time
  • Prerendering for static content, SSR for dynamic content - Correct! Prerender static pages, use SSR for user-specific or frequently updated content