---
title: "Prerendering Vue SPAs for SEO"
description: "Build-time and on-demand prerendering for client-side Vue apps. How to get SPA performance with SSR indexing."
canonical_url: "https://nuxtseo.com/learn-seo/vue/spa/prerendering"
last_updated: "2026-01-29"
---

<key-takeaways>

- Prerendering generates HTML at build time. deploy as static files to any CDN
- **Speculation Rules API** is the modern standard for speeding up SPA navigation
- Use `vite-ssg` for modern projects, `LovableHTML` for managed on-demand prerendering

</key-takeaways>

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](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics), 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](/learn-seo/vue/routes-and-rendering/rendering).

## Modern Prefetching: Speculation Rules API

While prerendering handles the *initial load*, the **Speculation Rules API** (widely supported in 2026) speeds up *subsequent navigation*. It tells the browser to pre-render the next page in a hidden background tab, making the transition instant.

This is superior to the old `<link rel="prefetch">` because it can execute JavaScript in the background before the user clicks.

```vue [components/Speculation.vue]
<script setup lang="ts">
import { onMounted } from 'vue'

// Prerender the /pricing page when this component mounts
onMounted(() => {
  const script = document.createElement('script')
  script.type = 'speculationrules'
  script.textContent = JSON.stringify({
    prerender: [
      {
        source: 'list',
        urls: ['/pricing', '/signup'],
        // 'moderate' triggers on hover/mousedown
        eagerness: 'moderate'
      }
    ]
  })
  document.head.appendChild(script)
})
</script>
```

Use this in your SPA to make navigation feel instant, which improves **Interaction to Next Paint (INP)** for page transitions.

## Build-Time vs On-Demand Prerendering

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

- 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** (LovableHTML, hosted render services):

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

Install:

```bash
npm install -D vite-ssg
```

Update your entry file:

<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 },
  ({ app, router, initialState }) => {
    // Install plugins, setup app
  }
)
```

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

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

</code-group>

Build generates HTML files matching your routes:

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

Deploy `dist/` to [Netlify](https://netlify.com), [Vercel](https://vercel.com), Cloudflare Pages. anywhere that serves static files.

**Limitations:**

- Define routes 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 (Legacy)

[prerender-spa-plugin](https://github.com/chrisvfritz/prerender-spa-plugin) uses Puppeteer to render pages. Works with [webpack](https://webpack.js.org) and Vue CLI, but the project saw its last update in 2019. **Do not use for new projects.** Use `vite-ssg` instead.

If you're on Vue CLI:

```bash
npm install -D prerender-spa-plugin
```

<code-group>

```js [vue.config.js]
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'
        })
      })
    ]
  }
}
```

```js [src/main.js]
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.mount('#app')

// Signal prerender completion
document.dispatchEvent(new Event('render-event'))
```

</code-group>

**Why vite-ssg wins:**

- Maintained actively
- No Puppeteer dependency (faster builds)
- Built for [Vite](https://vite.dev) (modern tooling)
- Better DX

## On-Demand: LovableHTML

[LovableHTML](https://lovablehtml.com/?via=nuxtseo) is a managed prerendering and SEO service for AI-built and client-rendered websites. It renders pages for crawlers, serves cacheable HTML for indexing, and keeps the normal SPA experience for users.

Setup with edge middleware:

<code-group>

```ts [middleware.ts]
export default async function middleware(request: Request) {
  const acceptsHtml = request.headers.get('accept')?.includes('text/html')
  if (request.method !== 'GET' || !acceptsHtml)
    return fetch(request)

  const response = await fetch(
    `https://lovablehtml.com/api/prerender/render?url=${encodeURIComponent(request.url)}`,
    {
      headers: {
        'x-lovablehtml-api-key': process.env.LOVABLEHTML_API_KEY!,
        'accept': 'text/html',
        'user-agent': request.headers.get('user-agent') || ''
      }
    }
  )

  if (response.status === 304)
    return fetch(request)

  return response
}
```

</code-group>

LovableHTML handles the crawler rendering layer for you, including cached HTML for search engines and AI crawlers. Your users still get fast client-side navigation.

**Costs:**

- Pay-as-you-go usage-based billing
- Useful when traffic or indexed page counts fluctuate

**When to use:**

- Content updates hourly but you can't use SSR
- Product catalogs with 1000+ pages
- You need managed prerendering, SEO audits, indexing help, and AI visibility without rebuilding the app

**Don't use if:**

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

## On-Demand: Rendertron (Deprecated)

[Rendertron](https://github.com/GoogleChrome/rendertron) is Google's open-source prerendering service. **It is no longer maintained.** Use a managed on-demand service like [LovableHTML](https://lovablehtml.com/?via=nuxtseo) or migrate to SSR.

## Detecting Prerendered Environment

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

<code-group>

```ts [vite-ssg]
import { onMounted } from 'vue'

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

```ts [prerender-spa-plugin]
if (window.__PRERENDER_INJECTED) {
  // Code that should only run during prerender
}
else {
  // Normal browser code
}
```

</code-group>

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:

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

## Dynamic Routes

Prerendering dynamic routes requires generating all possible paths:

```ts
// 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 like [LovableHTML](https://lovablehtml.com/?via=nuxtseo)

## Testing Prerendered Output

After building, verify Google sees content:

**1. Check static HTML**

```bash
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**

```bash
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**

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

[Google needs JavaScript to hydrate your app](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics). Never block `.js` or `.css` from Googlebot. See our [robots.txt guide](/learn-seo/vue/controlling-crawlers/robots-txt).

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

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

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

<table>
<thead>
  <tr>
    <th>
      Approach
    </th>
    
    <th>
      Cost
    </th>
    
    <th>
      Setup
    </th>
    
    <th>
      Best For
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <strong>
        vite-ssg
      </strong>
    </td>
    
    <td>
      Free
    </td>
    
    <td>
      Easy
    </td>
    
    <td>
      Modern Vue apps, <1000 routes
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        prerender-spa-plugin
      </strong>
    </td>
    
    <td>
      Free
    </td>
    
    <td>
      Medium
    </td>
    
    <td>
      Legacy Vue CLI projects (Legacy)
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        LovableHTML
      </strong>
    </td>
    
    <td>
      Pay-as-you-go
    </td>
    
    <td>
      Easy
    </td>
    
    <td>
      Frequent updates, managed SEO, can't use SSR
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Rendertron
      </strong>
    </td>
    
    <td>
      Hosting
    </td>
    
    <td>
      Hard
    </td>
    
    <td>
      <strong>
        Deprecated
      </strong>
    </td>
  </tr>
</tbody>
</table>

## 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 →](/learn-seo/nuxt/routes-and-rendering/rendering)
