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.tsdefines the prerender routes.- Each SEO page fires
custom-render-triggerwhen 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 pageindex-pricing.html— pre-rendered pricing pageindex-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:
- Create the Vue component with the render trigger in
onMounted - Add the route to your Vue Router configuration
- Add the path to the
routesarray invite.config.ts - Add a rewrite rule to your server config (Caddy/nginx)
- 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:
- Check the HTML files: Open the generated
index-*.htmlfiles in a text editor. The content should be present in the HTML, not just an empty<div id="app"></div>. - Check with curl: Run
curl https://yourdomain.com/pricingand verify the HTML contains your content. - 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
- What are OG Images? — why OG tags matter for your pre-rendered site
- OG Meta Tags Guide — all the tags to include in your pre-rendered head
- OG Preview Tool — verify your pre-rendered pages have correct OG tags
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?