Why Pre-render?
Vue.js is a single-page application (SPA) framework. By default, the HTML served to browsers and search engine crawlers is mostly empty — the content gets rendered by JavaScript after the page loads.
For SEO-critical pages (landing pages, articles, pricing pages), you want the HTML to contain actual content when served — not just visible text, but also meta tags like <title>, description, and Open Graph tags that search engines and social platforms read. Pre-rendering solves this by generating static HTML files at build time for specific routes.
This differs from server-side rendering (SSR) like Nuxt — pre-rendering happens once during build, not on every request. It's simpler to set up and works well for pages that don't need dynamic per-request content.
Bonus: Pre-rendered pages are fast to serve. The server just returns a static file — no server-side rendering logic per request. This also means you can serve them from a CDN.
The tradeoff: Pre-rendering adds to your build time. Each route spawns a headless browser, renders the page, and saves the HTML. It's reasonably fast — even up to a few minutes for ~1000 pages — but it's still extra time on every build.
Setting Up the Pre-renderer Plugin
Install the required packages:
pnpm add -D @prerenderer/rollup-plugin @prerenderer/renderer-puppeteer The plugin uses Puppeteer (headless Chrome) to render your pages. There's also 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`
},
}),
],
})The Render Trigger Event
The key configuration is renderAfterDocumentEvent. The pre-renderer waits for this custom DOM event before capturing the HTML. This ensures all your async data is loaded and rendered.
In each pre-rendered page component, dispatch this event in onMounted:
<script setup lang="ts">
import { onMounted } from "vue"
onMounted(() => {
document.dispatchEvent(new Event("custom-render-trigger"))
})
</script> If your page fetches data asynchronously, dispatch the event after the data loads — not immediately in onMounted. Otherwise the pre-rendered HTML won't include the fetched content.
Understanding the Output
After building (pnpm run build), you'll find multiple HTML files in your dist/ folder:
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 in the config above controls these filenames. The pattern index-{route-with-dashes}.html keeps things organized.
Server Configuration
Your web server needs to serve the pre-rendered HTML files for their respective routes. Here's a Caddy example:
: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;
}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
When adding a new SEO page, you need to update several files:
- 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
Missing any of these steps will cause issues — the page either won't pre-render, won't serve correctly, or won't get indexed.
Common Gotchas
Localhost URLs in Output
The pre-renderer runs against a local dev server. Without the postProcess URL replacement, your pre-rendered HTML will contain https://myog.social URLs. Always replace these with your production domain.
Missing Content
If your pre-rendered page is missing content, check that you're dispatching the trigger event after your async operations complete. Use the browser's "View Source" on the built files to verify the content is actually in the HTML.
Third-party Scripts
The skipThirdPartyRequests: true option prevents the pre-renderer from loading external scripts (analytics, etc). This speeds up builds and avoids errors from scripts that expect a real browser environment.
Memory Issues
With many routes, Puppeteer can consume significant memory. The maxConcurrentRoutes: 2 option limits parallel rendering. Increase it for faster builds if you have the memory, decrease it if builds fail.
Verifying Pre-rendering Works
After building, test that pre-rendering 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, adding pre-rendering is straightforward — you keep your existing codebase and just add a build plugin. Migrating to Nuxt means rewriting your app to fit Nuxt's conventions (file-based routing, different project structure, Nuxt-specific APIs).
Pre-rendering generates static HTML at build time. Your production server just serves files — no Node.js runtime needed. This is simpler to deploy and scale (CDN, static hosting, any web server). The tradeoff: content is fixed until the next build.
Nuxt/SSR generates HTML on every request. This requires a Node.js server running in production, adding operational complexity. The benefit: content can be dynamic per-request (user-specific data, real-time content).
Choose pre-rendering when you have a handful of SEO pages with static content. Choose Nuxt when most of your app needs SEO with per-request dynamic content, or you're starting fresh and the migration cost doesn't apply.