Guide

Vue Prerendering with Vite: Static SPA Routes

Set up Vue prerendering with Vite, static HTML routes, and meta tags that search crawlers can read before JavaScript runs.

The Short Answer

To prerender a Vue SPA with Vite, add @prerenderer/rollup-plugin, list the routes you want as static HTML, wait for a custom-render-trigger event, and map each URL to the generated HTML file in your server config.

For Vite projects, the important pieces are the prerender plugin, the SPA route list, the render trigger, and the production rewrite. Miss any of those and Google may still see an empty shell instead of the page content.

I use this for public SEO pages: articles, docs, pricing, and landing pages. The app still behaves like a normal Vue SPA after hydration.

Why Pre-render?

Vue is an SPA framework. The HTML your server sends to browsers and crawlers is mostly empty — JavaScript fills in the content after the page loads.

For SEO pages — landing pages, articles, pricing — you need actual content in the HTML when it's served. Not just visible text, but meta tags too: <title>, description, Open Graph tags. The stuff search engines and social platforms read. Pre-rendering fixes this by generating static HTML at build time for the routes you specify.

This differs from server-side rendering (SSR) like Nuxt — pre-rendering runs once during build, not per request. Simpler to set up, and it works well for pages that don't need dynamic per-request content.

Bonus: pre-rendered pages are fast. The server returns a static file — no rendering logic per request. You can serve them from a CDN.

The tradeoff: it adds to build time. Each route spawns a headless browser, renders the page, saves the HTML. Reasonably fast — a few minutes for ~1000 pages — but it's extra time on every build.

How It Works

Use @prerenderer/rollup-plugin with Vite, list the SPA routes you want as static HTML, dispatch a custom render event after Vue finishes rendering, then configure your server to serve the generated HTML file for each route.

For a Vue SPA, these are the moving parts:

  • vite.config.ts defines the prerender routes.
  • Each SEO page fires custom-render-trigger when content is ready.
  • The build writes one HTML file per route.
  • Caddy, nginx, or your host maps real URLs to those generated files.

Setting Up the Vite Prerender Plugin

Install the packages:

pnpm add -D @prerenderer/rollup-plugin @prerenderer/renderer-puppeteer

This uses Puppeteer (headless Chrome) under the hood. There's a renderer-jsdom option that's faster but less reliable for complex pages.

Configure vite.config.ts

Add the prerender plugin to your Vite config:

import prerender from "@prerenderer/rollup-plugin"

export default defineConfig({
  plugins: [
    vue(),
    prerender({
      routes: [
        "/",
        "/pricing",
        "/docs",
        "/articles/my-article",
      ],
      renderer: "@prerenderer/renderer-puppeteer",
      rendererOptions: {
        renderAfterDocumentEvent: "custom-render-trigger",
        maxConcurrentRoutes: 2,
        skipThirdPartyRequests: true,
      },
      postProcess(renderedRoute) {
        // Fix localhost URLs in the generated HTML
        renderedRoute.html = renderedRoute.html
          .replace(/https:/gi, "https:")
          .replace(
            /(https:\/\/)?(localhost|127\.0\.0\.1):\d*/gi,
            "https://yourdomain.com"
          )

        // Custom output filenames
        let name: string
        if (renderedRoute.originalRoute === "/") {
          name = "root"
        } else {
          name = renderedRoute.originalRoute.slice(1).replace(/\//g, "-")
        }
        renderedRoute.outputPath = `index-${name}.html`
      },
    }),
  ],
})

Prerendering SPA Routes

The routes array is the part people usually forget. The plugin does not discover every Vue Router route by itself. You tell it which paths matter for search: home, pricing, docs, articles, guides, and other public pages.

I treat this as an SEO allowlist. If a route should rank, it goes in the Vite prerender route list and gets a matching server rewrite. If it is an app screen behind login, I leave it as a normal client-rendered route.

I only prerender pages with stable public content. App screens, account pages, and anything that depends on the signed-in user should stay as normal SPA routes.

The Render Trigger Event

The key config is renderAfterDocumentEvent. The pre-renderer waits for this custom DOM event before capturing HTML. This makes sure all your async data is loaded and rendered before the snapshot.

In each page component you want pre-rendered, dispatch the event in onMounted:

<script setup lang="ts">
import { onMounted } from "vue"

onMounted(() => {
  document.dispatchEvent(new Event("custom-render-trigger"))
})
</script>

If the page fetches data asynchronously, dispatch the event after the data loads — not immediately in onMounted. Otherwise your pre-rendered HTML won't include the fetched content. I've made this mistake more than once.

Understanding the Output

After pnpm run build, you'll find multiple HTML files in dist/:

  • index.html — the default SPA entry point (used as fallback for non-prerendered routes)
  • index-root.html — pre-rendered home page
  • index-pricing.html — pre-rendered pricing page
  • index-articles-my-article.html — pre-rendered article

The postProcess function controls these filenames. I use the pattern index-{route-with-dashes}.html to keep them organized.

Server Configuration

Your web server needs to serve the right HTML file for each route. Here's what I use with Caddy:

:80 {
    root * /app/dist

    # Pre-rendered pages
    @root path /
    rewrite @root /index-root.html

    @pricing path /pricing
    rewrite @pricing /index-pricing.html

    @myarticle path /articles/my-article
    rewrite @myarticle /index-articles-my-article.html

    # SPA fallback for non-prerendered routes
    try_files {path} /index-default.html

    file_server
}

For nginx, the equivalent would use location blocks with try_files:

location = / {
    try_files /index-root.html =404;
}

location = /pricing {
    try_files /index-pricing.html =404;
}

location / {
    try_files $uri /index-default.html;
}

This is the part I check with curl, not the browser. The browser can hide the problem because Vue hydrates after load. Search crawlers need the HTML response itself to contain the route content, title, description, and Open Graph tags.

Skipping Pre-rendering in Development

For faster iteration during development, add an environment variable to skip pre-rendering:

// vite.config.ts
...(process.env.SKIP_PRERENDER
  ? []
  : [
      prerender({ /* config */ }),
    ]),

Then run SKIP_PRERENDER=1 pnpm run build for faster builds during development.

Checklist for Adding a New Pre-rendered Page

Adding a new SEO page touches several files. Miss one and something breaks:

  1. Create the Vue component with the render trigger in onMounted
  2. Add the route to your Vue Router configuration
  3. Add the path to the routes array in vite.config.ts
  4. Add a rewrite rule to your server config (Caddy/nginx)
  5. Add the URL to your sitemap.xml

I keep this as a checklist. The page either won't pre-render, won't serve correctly, or won't get indexed — and it's not always obvious which step you missed.

Common Gotchas

Route Is Missing from Vite Config

If the URL loads in your browser but the built HTML file does not exist, check the routes array first. Vue Router knowing about a route does not mean the prerender plugin will capture it.

Rewrite Is Missing in Production

If the file exists in dist/ but curl still returns the generic SPA shell, the server rewrite is missing or pointing at the wrong filename. I name files from the route path, so /articles/my-article becomes index-articles-my-article.html.

Localhost URLs in Output

The pre-renderer runs against a local dev server. Without the postProcess URL replacement, your HTML ends up with https://myog.social URLs baked in. Replace these with your production domain.

Missing Content

Pre-rendered page looks empty? Check that you're dispatching the trigger event after async operations complete. Open the built HTML file in a text editor — if the content isn't in the source, the trigger fired too early.

Third-party Scripts

skipThirdPartyRequests: true stops the pre-renderer from loading analytics and other external scripts. Speeds up builds and avoids errors from scripts expecting a real browser environment.

Memory Issues

Puppeteer eats memory with lots of routes. maxConcurrentRoutes: 2 limits parallel rendering. Bump it up if you have the RAM, dial it down if builds crash.

Verifying Pre-rendering Works

After building, verify it actually worked:

  1. Check the HTML files: Open the generated index-*.html files in a text editor. The content should be present in the HTML, not just an empty <div id="app"></div>.
  2. Check with curl: Run curl https://yourdomain.com/pricing and verify the HTML contains your content.
  3. Use Google's URL Inspection: In Search Console, inspect the URL to see how Google renders it.

Pre-rendering vs Nuxt (SSR)

If you already have a Vue + Vite app, pre-rendering is the path of least resistance. Keep your codebase, add a build plugin. Migrating to Nuxt means rewriting to fit its conventions — file-based routing, different project structure, Nuxt-specific APIs. That's a big investment if you just need a few SEO pages.

Pre-rendering generates static HTML at build time. Your production server just serves files. No Node.js runtime. Simple to deploy, easy to scale behind a CDN. The tradeoff: content is fixed until the next build.

Nuxt/SSR generates HTML per request. Requires Node.js running in production — more operational complexity. The upside: per-request dynamic content (user-specific data, real-time stuff).

I use pre-rendering for MyOG.social. I have a handful of SEO pages with mostly static content. Nuxt makes more sense when most of your app needs SEO with dynamic per-request content, or you're starting from scratch and the migration cost doesn't apply.

Related Resources

OG images for your Vue site

Add one meta tag and MyOG.social automatically generates OG images for every pre-rendered page.

Already have an account?

ace63667185965d5dad03e00b34309526e5f0607