Vite plugin for static SEO meta tags in Vue SPAs
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 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 set up @prerenderer/rollup-plugin for a Vue + Vite SPA.
If you're looking for 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.
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.
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.
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.
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