How we prerendered our own blog for AEO (without migrating off Vite)
The AEO pitch demands crawlable content. Our site was a SPA. Here's the scoped fix we shipped in an afternoon, and why we didn't rewrite the stack.
Every time we pitch Pharos, some version of this sentence comes out: "If AI crawlers can't read your content, they can't cite you." Three weeks into our own launch, we caught ourselves not following it. heypharos.com was a Vite SPA — the thing we tell buyers is the worst possible shape for AEO.
This is how we fixed it, why we didn't do the obvious thing (migrate to Next.js), and what we think the general rule is.
The problem, in one paragraph
When GPTBot, ClaudeBot, PerplexityBot, or any of the AI retrievers fetch an SPA URL, the server's response body is effectively empty — a root div, a script tag, a few meta tags. The article the bot came to read is fetched and assembled by JavaScript a few hundred milliseconds later, in the browser. A browser will wait. Most AI crawlers won't. Google and Bing do execute JS, but on a budget, with caveats, and only some of the time. If your blog depends on that JS execution to become content, it's a coin flip whether anyone outside a real browser ever reads it.
That was us. Five blog posts. Zero of them readable by a crawler that doesn't run JS. Including the post explaining that crawlers that don't run JS won't read your blog.
The obvious fix, and why we didn't take it
The textbook solution is: migrate to Next.js, render server-side, done. For a brand new team with a clean two-day window, that's the right call. For us, staring at a 144-component app, 38 routes, framer-motion scroll effects, and a three.js lighthouse on the homepage — it was two full days of careful work to get back to where we already were visually, plus the risk surface of touching everything.
We asked the more useful question: which pages actually need to be crawlable? The marketing surface (pricing, features, solutions) is a conversion layer. No AI engine is going to cite /pricing in response to "what's the best AEO platform." The pages that AI engines cite are blog posts, research, long-form content. The problem was narrower than the frame we started with.
What we shipped
A 200-line postbuild script that runs after Vite finishes. It reads the built dist/index.html (the SPA shell), queries Sanity for every published post, and for each post writes dist/blog/<slug>/index.html — a standalone HTML file that's a copy of the shell plus:
A page-specific <title>, meta description, canonical URL, and OpenGraph/Twitter card tags — so social shares render properly and each post has its own identity in a crawler's index.
A JSON-LD <script> in the <head> containing a full schema.org/Article object. The critical field is articleBody — the entire post rendered to plain text. Google's AI Overviews and Gemini explicitly use this. It's the single highest-leverage signal in the file.
A <noscript> block in the <body> carrying the full article HTML. Users with JavaScript (everyone) never see it. Crawlers that don't execute JS (most AI retrievers) parse it as the canonical content. Some SEO camps are nervous about <noscript>; we're comfortable because the content is a faithful representation of what the React app eventually renders — no cloaking.
The React app still boots on top of the static HTML and hydrates the visible UI with its usual components. Users see no difference. Bots see 100× more.
Plus the housekeeping
The same script writes dist/sitemap.xml — every marketing route plus every blog URL with its publishedAt as lastmod. We updated robots.txt to explicitly allow-list GPTBot, ClaudeBot, PerplexityBot, Google-Extended, OAI-SearchBot, CCBot, and Perplexity-User. It's redundant with our existing wildcard-allow, but being explicit is a stronger signal to crawler operators than a wildcard that may or may not apply to their specific bot.
The trade-off we accepted
Client-side navigation to a blog post from the blog index is slightly slower than it was — instead of a fast in-app transition, the browser does a full HTML fetch of the new page. We're fine with that: most blog readers arrive from outside the site on cold navigation anyway, and shaving 200ms on an in-site click is a strictly worse problem to optimize for than being invisible to AI citations.
The other trade-off: the script is maintenance the Next.js version wouldn't be. If we add a new content type (research, guides, changelog), we need to extend the script. For a five-post blog that's fine. At 200 posts, with three content types, we'd reconsider.
The general rule
Prerender the minimum surface that needs to be crawlable. Don't rewrite your stack if you can serve static HTML for the 5% of pages that actually matter for citations.
The AEO story isn't "every page must be server-rendered." It's "every page a model might quote must be readable without JavaScript." Those are different rules. The first one leads you to a framework rewrite. The second one leads you to a build script you can ship in an afternoon. Pick the second one first, and only escalate if the volume or shape of your content outgrows it.
This post is itself prerendered by the same script. If an AI engine cites it, that's the feedback loop closing.