Vue pre-rendering with Vite: static SEO meta tags without SSR
Vue + Vite sites often ship as SPAs. The HTML your server sends to browsers and crawlers is mostly empty — a <div id="app"></div> and a bundle of JavaScript. The actual content renders client-side after the page loads.
For most of your app, that's fine. But for SEO pages — landing pages, articles, pricing, docs — you need Google to receive static HTML when it crawls the route. Not just the visible text, but the <title>, description, Open Graph tags, Twitter Card meta tags, canonical URL, and route content. Without these in the initial HTML, your pages have weak snippets and social previews can be blank.
The fix I use is @prerenderer/rollup-plugin. It lets a Vue + Vite SPA keep its normal client-side architecture while shipping static HTML files for selected routes. Those files include the route-specific title, description, Open Graph tags, Twitter tags, canonical URL, and rendered page content before JavaScript runs.
This is also the point where I would pause before buying a Vue pre-render service provider. If your SEO routes are known at build time, a Vite prerender step is usually enough. No crawler proxy, no hosted render cache, no separate bot-serving path to debug.
The usual answer is SSR with Nuxt. But if you already have a Vue + Vite app, migrating to Nuxt means rewriting your project to fit its conventions — file-based routing, different project structure, Nuxt-specific APIs. That's a lot of work for what might be 10-20 SEO pages out of hundreds of routes.
Pre-rendering is the simpler option. It doesn't require any changes to your app's architecture, and it adds zero runtime cost — your production server just serves static files. No Node.js runtime, no server-side rendering on each request. It generates static HTML files at build time for the Vite routes you specify, including route-specific SEO meta tags.
Here's how to pre-render Vue SPA routes with Vite and @prerenderer/rollup-plugin.
If you're looking for Vue prerendering, vite-plugin-prerender, or a Vite plugin for static SEO meta tags in a SPA — static HTML, prerendered routes, page titles, descriptions, Open Graph tags, and canonical URLs — this is the setup. The important part is not only rendering the visible route content. Google and social crawlers also need the head tags to be present in the generated HTML before JavaScript runs.
Stacknaut uses this for articles, docs, comparison pages, tools, and pricing. If you want the finished version instead of wiring it yourself, see the full Stacknaut TypeScript setup and deployment docs.
How to set up prerendering in Vue.js
The setup has three moving parts:
- a Vite plugin that renders selected routes during the build
- a router guard that writes route-specific SEO tags before capture
- a server rewrite that serves the generated HTML file for each route
The server rewrite is easy to miss. Generating index-articles-my-article.html is not enough if /articles/my-article still serves the generic SPA shell. Your production server needs an exact route match for each pre-rendered page.
In my projects, I keep the list deliberately boring:
vite.config.tslists the routes to pre-render- Vue Router stores the title and description for each route
- Caddy rewrites
/docs/deploymenttoindex-docs-deployment.html sitemap.xmllists the canonical public URLs
You do not need a crawler service, an SSR runtime, or a separate cache to invalidate.
Do you need a Vue pre-render service provider?
Most Vue pre-render service providers solve the same crawler problem from outside your app. They run a browser, render each URL, cache the HTML, and serve that snapshot to bots. That can work when you cannot change the build pipeline, but it adds another hosted service, another cache layer, and another output to debug when production does not match your deploy.
For a Vue + Vite app with a known set of SEO routes, build-time pre-rendering is usually simpler. The route list lives in vite.config.ts, the generated HTML ships with the same deploy as your app, and your web server can serve exact static files for /pricing, /articles/..., and /docs/....
Use a hosted pre-render provider when you have many dynamic URLs, cannot touch the build, or need emergency rendering for an existing SPA. Use build-time pre-rendering when your SEO pages are mostly static and you want the setup to stay in your repo.
The comparison I use:
| Need | Hosted pre-render service | Vite build-time pre-rendering |
|---|---|---|
| Existing SPA you cannot rebuild | Good fit | Usually not enough |
| Static marketing pages, docs, pricing, articles | Extra moving part | Good fit |
| Thousands of dynamic URLs | Better fit | Awkward unless you generate the route list |
| Simple deploy/debug workflow | More to inspect | One build artifact and one server rewrite |
For Stacknaut, the second column wins. The public SEO pages are known routes, so I want static HTML files checked into the normal build output and served by Caddy with exact rewrites.
Install the plugin
pnpm add -D @prerenderer/rollup-plugin @prerenderer/renderer-puppeteer
The plugin uses Puppeteer (headless Chrome) to render your pages. There's a renderer-jsdom option that's faster but less reliable for complex pages.
If you are searching for vite-plugin-prerender, check the maintenance status before adding it. The underlying idea is the same — render selected SPA routes at build time — but I prefer @prerenderer/rollup-plugin because it fits Vite's Rollup build pipeline directly and does not require a special production server.
Configure Vite prerender routes
Add the prerender plugin to vite.config.ts and list every SEO route that needs static HTML:
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) {
renderedRoute.html = renderedRoute.html
.replace(/https:/gi, "https:")
.replace(/(https:\/\/)?(localhost|127\.0\.0\.1):\d*/gi, "https://yourdomain.com")
let name: string
if (renderedRoute.originalRoute === "/") {
name = "root"
} else {
name = renderedRoute.originalRoute.slice(1).replace(/\//g, "-")
}
renderedRoute.outputPath = `index-${name}.html`
},
}),
],
})
The postProcess function replaces localhost URLs with your production domain and generates output filenames like index-pricing.html instead of nested directories.
The render trigger
The key configuration is renderAfterDocumentEvent. The pre-renderer waits for this custom DOM event before capturing the HTML. That gives the page time to render content and async data before the HTML gets saved.
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.
Setting meta tags per route
Pre-rendering captures whatever's in the DOM when the trigger fires. That includes meta tags. I set these in the Vue Router's beforeEach guard — each route defines its title, description, and optional ogImage in meta:
const router = createRouter({
routes: [
{
path: "/pricing",
component: () => import("@/views/PricingView.vue"),
meta: {
title: "Pricing — My App",
description: "One-time purchase. Ship your SaaS faster.",
},
},
],
})
router.beforeEach((to) => {
if (to.meta?.title) {
document.title = to.meta.title as string
}
const description = (to.meta?.description as string) || defaultDescription
document.querySelector('meta[name="description"]')?.setAttribute("content", description)
document.querySelector('meta[property="og:description"]')?.setAttribute("content", description)
document.querySelector('meta[name="twitter:description"]')?.setAttribute("content", description)
// Same pattern for og:title, twitter:title, og:image, etc.
})
The beforeEach guard updates the meta tags before the page component mounts. When the pre-renderer captures the HTML after the render trigger, these tags are already in place. Search engines and social platforms get the correct metadata from the static HTML — no JavaScript execution needed on their end.
Vite plugin static SEO meta tags for SPA routes
For a Vite SPA, "static SEO meta tags" means the built HTML file for each route already contains the right <title>, description, og:title, og:description, twitter:title, twitter:description, and canonical URL. The crawler should not need to execute your Vue app before it can understand the page.
The flow:
- Vue Router stores route-specific SEO metadata in
meta - The router guard writes those values into
document.head - The page component waits until content is ready
- The page dispatches
custom-render-trigger @prerenderer/rollup-plugincaptures the route as static HTML- Caddy, nginx, or your static host serves that route-specific HTML file
This is different from putting a few generic tags in index.html. Generic tags give every route the same snippet. Pre-rendered route metadata gives /pricing, /docs/deployment, and /articles/my-article their own searchable titles and descriptions.
In Stacknaut, the same pattern powers SEO pages such as the Kamal vs Coolify comparison, the Kamal deployment docs, and the Terraform Hetzner infrastructure docs. Those pages are Vue routes, but search engines receive static HTML with route-specific metadata.
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 (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 pattern is index-{route-with-slashes-replaced-by-dashes}.html. Open any of these in a text editor and you'll see actual HTML content — not an empty <div id="app"></div>.
Server configuration
Your web server needs to serve the right HTML file for each pre-rendered route. Here's a Caddy example:
:80 {
root * /app/dist
@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.html
file_server
}
For nginx:
location = / {
try_files /index-root.html =404;
}
location = /pricing {
try_files /index-pricing.html =404;
}
location / {
try_files $uri /index.html;
}
Specific route matches serve pre-rendered files, and the SPA fallback handles everything else.
For Google crawling, this is the point to verify. Run curl https://yourdomain.com/articles/my-article and check that the response contains the article title, description, canonical URL, and rendered body text before JavaScript runs. If you only see <div id="app"></div>, the page is still relying on client-side rendering.
Skipping pre-rendering in development
Pre-rendering adds to your build time — each route spawns a headless browser instance. For faster iteration during development, add an environment variable to skip it:
...(process.env.SKIP_PRERENDER
? []
: [
prerender({ /* config */ }),
]),
Then run SKIP_PRERENDER=1 pnpm run build for fast builds during development.
Checklist for adding a new pre-rendered page
Every time you add 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 config with
titleanddescriptioninmeta - 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
Miss any of these and the page either won't pre-render, won't serve correctly, or won't get indexed. It's manual, but predictable — and easy to hand off to an AI coding agent if you document the steps in your AGENTS.md.
In my projects, the rule goes in AGENTS.md. New public SEO pages must update route metadata, prerender routes, web server rewrites, sitemap, and static output. Then the agent has a checklist instead of guessing. Stacknaut includes that in the AI agent config.
Common gotchas
Localhost URLs in output. Without the postProcess URL replacement, your pre-rendered HTML will contain local dev server 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 async operations complete. Use "View Source" on the built files to verify the content is in the HTML.
Third-party scripts. skipThirdPartyRequests: true 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. With many routes, Puppeteer can consume significant memory. Set maxConcurrentRoutes to 2 to limit parallel rendering. Increase it for faster builds if you have the memory, decrease it if builds fail.
Pre-rendering vs Nuxt
If you already have a Vue + Vite app, adding pre-rendering is a few hours of work — or a few minutes with an AI coding agent. Migrating to Nuxt means rewriting your app and adding runtime cost to your server.
Pre-rendering generates static HTML at build time. Your production server just serves files — no Node.js runtime needed. 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 in production, adding operational complexity. The benefit is that content can be dynamic per-request.
Choose pre-rendering when you have a handful of SEO pages with mostly 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.
Already set up in Stacknaut
If you're using Stacknaut, all of this is already configured. The starter kit ships with @prerenderer/rollup-plugin wired up, the Caddy server config with rewrite rules, the beforeEach guard for meta tags, sitemap rules, and the AGENTS.md checklist so your AI coding agent knows how to add new pre-rendered pages without you having to explain the process each time.
No runtime cost, no architecture changes. You just create the page component, and the agent handles the rest — adding the route, the prerender config, the Caddyfile rewrite, and the sitemap entry. That's the kind of thing that should be automated.
If you're also deciding where to deploy that Vue SPA, compare Kamal vs Coolify and self-hosting with Hetzner/Kamal vs Vercel or Render. Pre-rendering solves the crawler problem; deployment still decides your cost, control, and production workflow.
What Stacknaut gives you
A production SaaS codebase for coding agents.
Three private repos: the app, the deployment/infrastructure setup, and the AI agent configuration that ties the workflow together.
What you get in Stacknaut
- Vue, Fastify, Drizzle, Stripe, Postgres, email, jobs, and shared TypeScript
- Terraform + Kamal deployment for a single Hetzner server
- AGENTS.md and skills designed for Claude Code, Codex, Cursor, and Droid