← Blog · June 20, 2026

Making an SPA blog visible to AI engines: build-time prerender

This blog is a Vue SPA. The problem: most AI crawlers — GPTBot, ClaudeBot, PerplexityBot — don’t run JavaScript. So when they hit /blog/<slug>, all they see in the raw HTML is an empty <div id="app">. My most citable content — posts full of code and numbers — was invisible to them.

Solving it without SSR

Standing up full SSR (moving to Nuxt and so on) is overkill for a six-post blog. There’s a smaller path: prerender on postbuild. A Node script that runs after the Vite build takes dist/index.html as a template and writes a static page per post:

const template = readFileSync("dist/index.html", "utf8");
let html = template
  .replace(/<title>.*?<\/title>/, `<title>${post.title}</title>`)
  .replace("</head>", `${blogPostingJsonLd}</head>`)
  .replace(/<div id="app">[\s\S]*?<\/div>/, `<div id="app">${articleHtml}</div>`);
writeFileSync(`dist/blog/${post.slug}/index.html`, html);

The result: dist/blog/<slug>/index.html — body, per-route canonical, and BlogPosting/FAQPage JSON-LD all in raw HTML, no JS needed. nginx serves it redirect-free with try_files $uri $uri/index.html; Vue takes over #app on mount, so JS users notice nothing.

Why it works

A crawler that doesn’t run JS now reads real text instead of an empty shell. Google’s JavaScript SEO guide talks about the cost of rendering; most AI engines never pay that cost — the rendered content has to be there for them already. There’s no prerender for an unknown slug, so nginx falls back to index.html and the SPA shows its own 404.

A tiny postbuild script solved the problem a whole SSR migration would have — exactly enough at this blog’s scale.