Articles

How to Pre-render a Vue + Vite Site for SEO (No SSR Required)

Vue is an SPA framework. The HTML your server sends to browsers and crawlers is mostly empty — a <div id="app"></div> and a bunch 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 the HTML to contain real content when served. Not just the visible text, but the <title>, description, Open Graph tags, and Twitter Card meta tags that search engines and social platforms read. Without these in the initial HTML, your pages won't rank and your link previews will 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 routes you specify. Here's how to set it up.

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.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) {
        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 does two things: it replaces localhost URLs with your production domain (since the pre-renderer runs against a local dev server), and it generates custom 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. This ensures all your content — including async data — is loaded and rendered 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.

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 page
  • index-pricing.html — pre-rendered pricing page
  • index-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;
}

The key: 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 title and description in meta
  • Add the path to the routes array in vite.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 a manual process, but it's predictable — and easy to hand off to an AI coding agent if you document the steps in your AGENTS.md.

Common Gotchas

Localhost URLs in output. Without the postProcess URL replacement, your pre-rendered HTML will contain https://stacknaut.com 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 actually 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. maxConcurrentRoutes: 2 limits 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: 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, 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.

b6c0e1bd

© 2026 Stacknaut