I like Vue SPAs for SaaS apps. The app is mostly behind login, and the marketing pages are usually the only SEO-sensitive pages.

That does not mean I want SSR.

For my use case, Vite static output plus prerendered HTML is enough: generate real HTML for public routes at build time, write the SEO tags into each page, and serve static files.

The problem with a plain SPA

A plain Vite SPA usually ships one index.html:

<div id="app"></div>
<script type="module" src="/src/main.ts"></script>

Vue Router handles the route after JavaScript loads.

A plain SPA is fine for an app dashboard. It is not enough for public pages where I care about search previews, canonical URLs, and content being visible in the initial HTML.

For SEO pages, I want this in the static output:

<title>Kamal vs Coolify for Solo SaaS Deployment</title>
<meta name="description" content="A practical comparison of Kamal and Coolify..." />
<link rel="canonical" href="https://example.com/kamal-vs-coolify/" />

<meta property="og:title" content="Kamal vs Coolify for Solo SaaS Deployment" />
<meta property="og:description" content="A practical comparison of Kamal and Coolify..." />
<meta property="og:url" content="https://example.com/kamal-vs-coolify/" />

And I want the page body rendered too.

No need to make the server render every request if the content changes when I deploy anyway.

Keep the route data in one place

The first step is to stop scattering SEO data across components.

I keep public route metadata in a structured list:

export const publicPages = [
  {
    path: "/kamal-vs-coolify/",
    title: "Kamal vs Coolify for Solo SaaS Deployment",
    description:
      "A practical comparison of Kamal and Coolify for solo SaaS apps.",
    ogImage: "https://example.com/og/kamal-vs-coolify.png",
  },
  {
    path: "/vue-prerendering-without-ssr/",
    title: "Vue Prerendering Without SSR",
    description:
      "How to prerender Vue marketing pages at build time without moving to SSR.",
    ogImage: "https://example.com/og/vue-prerendering.png",
  },
] as const

Vue Router can use the same data:

export const routes = publicPages.map((page) => ({
  path: page.path,
  component: () => import("./pages/MarketingPage.vue"),
  meta: {
    title: page.title,
    description: page.description,
    ogImage: page.ogImage,
  },
}))

The prerender script can use it too.

That avoids the usual mess where the browser title says one thing, the sitemap says another, and the generated HTML has an old description.

The prerender script writes HTML

I usually keep this as a small build step after Vite builds the client bundle.

The rough flow:

bun run build
bun run prerender

The prerender script:

  • reads the route list
  • loads the built dist/index.html
  • renders or injects page HTML for each public route
  • writes dist/<route>/index.html
  • replaces the SEO tags per route
  • writes or updates sitemap.xml

The meta tag replacement can stay boring:

function escapeHTML(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
}

function withSeoTags(html: string, page: PublicPage): string {
  const canonicalURL = new URL(page.path, "https://example.com").toString()
  const title = escapeHTML(page.title)
  const description = escapeHTML(page.description)

  return html
    .replace(/<title>.*?<\/title>/, `<title>${title}</title>`)
    .replace(
      /<meta name="description" content=".*?">/,
      `<meta name="description" content="${description}">`,
    )
    .replace(
      "</head>",
      [
        `<link rel="canonical" href="${canonicalURL}">`,
        `<meta property="og:title" content="${title}">`,
        `<meta property="og:description" content="${description}">`,
        `<meta property="og:url" content="${canonicalURL}">`,
        `<meta property="og:image" content="${page.ogImage}">`,
        "</head>",
      ].join("\n"),
    )
}

You can use an HTML parser if you want. The important part is simpler: every SEO route gets its own static HTML file.

Do not rely on the browser title

It is tempting to set all of this from Vue Router:

router.afterEach((to) => {
  document.title = String(to.meta.title)
})

I still do that for the running app. It is useful when users navigate between pages after JavaScript loads.

But it does not solve static SEO output. The generated HTML still needs the title and meta tags before the browser runs the app.

So I treat router metadata as input, not the whole solution. The prerender step writes the final tags into each index.html.

The browser can update the title later. Google can render JavaScript, but that still adds another step, and not every crawler or link preview bot runs JavaScript. I do not want the important tags to depend on that.

Add a check so pages do not silently regress

The easiest mistake is adding a new public route and forgetting to add it to the prerender list.

I like a small script that fails the build when a public SEO route is missing static output:

import { existsSync } from "node:fs"
import { join } from "node:path"
import { publicPages } from "../src/publicPages"

const missing = publicPages.filter((page) => {
  return !existsSync(join("dist", page.path, "index.html"))
})

if (missing.length > 0) {
  console.error(
    missing.map((page) => `Missing prerender output: ${page.path}`).join("\n"),
  )
  process.exit(1)
}

Then the build flow becomes:

bun run build
bun run prerender
bun run check:prerender

The check is especially useful with coding agents. If the agent adds /compare/kamal-vs-coolify/ but forgets to include it in the prerender list, the script catches it.

I would rather have a boring build failure than discover the problem in Search Console two weeks later.

Canonical URLs matter

SPAs make it easy to accidentally serve the same content from multiple URLs:

  • /pricing
  • /pricing/
  • /index.html
  • /?route=pricing

Pick one.

I prefer trailing slashes for static routes because they map cleanly to directories:

dist/pricing/index.html
dist/kamal-vs-coolify/index.html

Then the canonical URL should match exactly:

<link rel="canonical" href="https://example.com/pricing/" />

The sitemap should use the same URL. Internal links should use the same URL. Do not make Google decide which version you meant.

What goes into the sitemap

For static marketing routes, the sitemap should come from the same page list:

const urls = publicPages.map((page) => {
  const loc = new URL(page.path, "https://example.com").toString()
  return `<url><loc>${loc}</loc></url>`
})

I do not want a second list of URLs just for sitemap.xml.

The practical rule:

  • if a public route should rank, it belongs in publicPages
  • if it belongs in publicPages, it gets prerendered
  • if it gets prerendered, it goes in the sitemap
  • if it should not rank, mark it noindex or keep it out
  • if it has internal links, use normal <a href="..."> links, not hash routes

That keeps the SEO surface small enough to reason about.

When this is enough

Build-time prerendering works well for:

  • landing pages
  • pricing pages
  • docs pages
  • comparison pages
  • changelog pages
  • mostly static articles

It is less useful for pages where the HTML needs to change per request:

  • user profiles that update constantly
  • pages personalized by auth state
  • inventory pages with minute-by-minute changes
  • search results

For those, SSR or server-generated pages may be worth it.

Most SaaS marketing sites do not need that. They need stable public pages with good titles, descriptions, canonical URLs, and Open Graph previews.

Why I use this in SaaS starter kits

Stacknaut uses Vue because I like the productivity of Vue for app work. I do not want to give up that app structure just because the marketing site needs SEO.

The setup I like:

  • Vue stays Vue
  • Vite still outputs static files
  • public pages get real HTML
  • SEO tags are generated from route data
  • deployment stays simple

I get static output that search engines and social previews can read, without running an SSR server or moving to another framework.

Where this breaks

The weak spot is dynamic content.

If a page changes every few minutes, prerendering at deploy time is the wrong tool. You either need server-rendered HTML, a separate static regeneration job, or a page that is not trying to rank.

I also avoid prerendering anything that depends on auth state. A logged-in dashboard should not be in the sitemap. The HTML can be empty until the app loads because search traffic is not the point of that page.

That split keeps the architecture simple:

  • public marketing pages get prerendered HTML
  • logged-in app pages stay normal SPA routes
  • dynamic SEO pages need a separate decision

Most of my SaaS work fits that split.

I also keep the app shell separate from the marketing pages mentally. The app can have loading states, auth forwarding, and client-only data fetching. The marketing pages need stable text, stable URLs, and stable previews.

The two sides have different jobs. Trying to make one route mode handle everything is how a simple SPA turns into a half-SSR framework project by accident.

If I need that later, fine. I would rather start with static output and add server rendering only when a page proves it needs it.

The other place this can break is URL forwarding.

If /pricing forwards to /pricing/, make the canonical URL /pricing/ and put /pricing/ in the sitemap. Do not mix both forms across nav links, generated pages, and marketing copy.

I also like checking the generated output directly:

rg -n "<title>|canonical|og:title|description" dist/pricing/index.html

For a small site, that one command catches most mistakes. If the title is generic, the canonical is wrong, or the description is missing, I can fix the page metadata before deploy.

For a larger site, I turn that into a script and make the build fail. The rule is the same either way: inspect the static HTML that ships to production.

That is the whole point of prerendering. The output file is the thing search engines and preview bots see first.