- A client-rendered single-page app sends crawlers a near-empty HTML shell, and you can't rely on AI crawlers such as OAI-SearchBot, ClaudeBot or PerplexityBot running JavaScript to fill it in.
- Googlebot renders JavaScript, so an SPA can rank in Google and still be missing from ChatGPT, Claude and Perplexity answers.
- There are three rendering fixes for an existing SPA (server-side rendering, edge prerendering for crawlers, or build-time static generation), and a marketing site can also be rebuilt on a platform that server-renders by default.
- Schema markup only reaches non-JavaScript crawlers if it is in the first HTML response, so put site-wide JSON-LD in index.html and per-page JSON-LD in prerendered or server-rendered HTML.
- brandinglab.io ran as a Lovable SPA behind a Cloudflare Worker prerender before I moved it to Webflow, which serves finished HTML by default.
Open a terminal. Pick any URL on your marketing site. Run this:
curl -s https://yoursite.com/your-best-article | head -100Read what comes back.
If your site is a single-page application (React, Vue, Angular or Svelte without server rendering), there's a good chance the response is a near-empty HTML shell: a <div id="root"></div>, a script tag and almost nothing else. No headline, no body copy, no schema, no author, no date. Nothing for an answer engine to read, summarise or cite.
I know this one from the inside. My own site, brandinglab.io, ran as a Lovable React SPA, and I had to fix this exact problem before I moved it to Webflow. I'm Russel Shalom Davis, founder of BrandingLab in Ramat Beit Shemesh, Israel, and I build B2B brands and Webflow sites designed to be cited by AI search. This article explains why SPAs go missing from AI answers, how to test your own site, the three ways to fix it with their real costs, and how to add JSON-LD to a React SPA without fooling yourself.
You can fix it, but not with better copy. The fix is in how the site is built.
Why can't AI crawlers see my single-page app?
AI crawlers can't see most single-page apps because the server sends the same near-empty HTML shell for every URL and the content only appears after JavaScript runs in a browser, which many AI crawlers don't do. Whatever isn't in that first response, they never read.
A server-rendered website works differently. The browser asks for a URL, the server builds the full HTML for that page and sends it back, so everything a human sees is in the raw response. In an SPA, the browser downloads a JavaScript bundle, runs it, fetches data and builds the page in memory. A person sees a brief loading state, then the finished page. A crawler issues one HTTP request, parses what comes back and decides whether to run the JavaScript. That choice belongs to the crawler's operator, weighed against cost and scale.
Here is how the crawlers that matter behave. Treat it as a dated snapshot, because these systems change quietly:
| Crawler | Operator | What it's for | Runs JavaScript? |
|---|---|---|---|
| Googlebot | Search index, AI Overviews and AI Mode | Yes, in a rendering queue | |
| Bingbot | Microsoft | Bing and Copilot | Yes, with limits |
| OAI-SearchBot | OpenAI | ChatGPT search results | Don't rely on it |
| ChatGPT-User | OpenAI | Fetching pages when a user asks | Don't rely on it |
| GPTBot | OpenAI | Model training | Didn't execute JS in Vercel/MERJ tests |
| Claude-SearchBot, Claude-User, ClaudeBot | Anthropic | Search, user fetches, training | ClaudeBot didn't execute JS in Vercel/MERJ tests |
| PerplexityBot, Perplexity-User | Perplexity | Search index, user fetches | Don't rely on it |
| LinkedInBot, Slackbot, Twitterbot, facebookexternalhit | Social previews | Link previews | No, so SPA previews break |
Sources: OpenAI's crawler list, Anthropic's crawler list, Perplexity's crawler list, Google's crawler list.
Two notes on the table. Google-Extended is a robots.txt control token with no crawler of its own: it decides whether your content is used for Gemini training and grounding, and it doesn't affect AI Overviews or AI Mode, which use Googlebot. And the JavaScript evidence is from Vercel and MERJ's late-2024 study, which found OpenAI's and Anthropic's crawlers fetching JavaScript files without executing them (Vercel). Since then there have been unconfirmed reports of OpenAI crawlers rendering some JavaScript (Indie Hackers). That doesn't change the advice. The AI companies chose not to run JavaScript for every page because it is expensive at their scale, and you can't build a marketing channel on a behaviour that might change.
So the rule is simple: if your content isn't in the HTML from the first request, assume AI crawlers can't see it.
Why does this matter for B2B companies now?
It matters now because B2B buyers increasingly ask an AI assistant for a shortlist before they ever see a page of links, and an assistant can only name companies whose pages it could read. ChatGPT is reported at around 800–900 million weekly users in 2026 (Searchlab), and Google's AI Mode passed a billion monthly users (MarketBeat).
When a buyer asks "which agencies build B2B websites that show up in AI search?" or "what's the difference between SEO and AEO?", the engine composes an answer from sources it has read and names some of them. If the answer names three vendors and you're not one of them, you lost that buyer without ever knowing. An SPA that serves an empty shell can't be one of the three, however good its content is.
How do I test whether AI crawlers can read my site?
Test it by fetching your page without JavaScript, with curl or View page source, and checking whether the headline, body copy and JSON-LD are in the raw HTML. Three checks take about five minutes.
Test 1: the curl test
curl -s https://yoursite.com/your-most-important-page | head -100A healthy result contains your headline, body copy, structured data and meta tags in readable HTML. A failing result contains a generic <title>, a few meta tags, an empty <div id="root"> and some <script> tags. Repeat it on three page types: a marketing page, an article and a case study. Mixed results across page types are common and informative.
One caution if you add a crawler user agent, as in curl -A "GPTBot". If your site uses a prerender layer that matches on the user-agent string (like the Cloudflare Worker I ran), the spoofed request shows what crawlers get. If your platform verifies crawlers by other means, as Lovable does for older projects (Lovable), a request from your laptop that only claims to be GPTBot gets the shell, and you'll conclude you're invisible when you aren't. In that case, check Google Search Console's URL Inspection and your server logs instead. The platform-by-platform version of this is in vibe-code platforms for AEO.
Test 2: the view-source test
In your browser, right-click and choose View page source, not Inspect. View page source shows the raw HTML the server sent; Inspect shows the live page after JavaScript has run. Crawlers without JavaScript see the first. If the article body isn't in the source as readable text, the page fails the basic AEO test.
Test 3: the schema test
Search the raw HTML from Test 1 or Test 2 for application/ld+json. Each match is one block of structured data a non-JavaScript crawler can read.
Don't use Google's Rich Results Test for this step. It renders JavaScript, so schema injected by your app passes even though non-JavaScript crawlers never see it. I learned that on my own site: the Rich Results Test came back green while the per-page schema was missing entirely (the story is in the react-helmet-async v3 bug). The Rich Results Test tells you whether markup is valid. Only the raw HTML tells you who can read it.
What are the ways to fix an invisible SPA?
There are three rendering fixes for an existing SPA: server-side rendering, an edge prerender layer for crawlers, or static HTML generated at build time. Each has a different cost, timeline and maintenance load, and for a marketing site there's a fourth option: rebuild it on a platform that serves finished HTML by default.
Path 1: server-side rendering
The most complete fix is to move onto a framework that renders HTML on the server by default: Next.js or React Router's framework mode for React, Nuxt for Vue, SvelteKit for Svelte, Astro across frameworks, or TanStack Start. Lovable has built new projects on TanStack Start with server-side rendering since 13 May 2026 (Lovable); projects created before that get prerendering for verified crawlers instead.
These frameworks handle each request on a server or serverless function, run your components, fetch data and return finished HTML, which the browser then hydrates. Crawlers get the same HTML as people on the first request.
When it's right: you publish regularly (articles, case studies, product pages, docs) and new content needs to be readable within minutes.
What it costs: a real migration. Routing, data fetching, metadata and authentication all change shape, so plan in weeks for a content-heavy marketing site. Hosting moves to a platform that supports the framework.
What you keep: your components, design system, CMS or database, business logic and domain.
What you gain beyond AEO: faster first loads, better Core Web Vitals and working social previews.
What you take on: a server-side runtime to maintain, monitor and pay for.
Path 2: edge prerendering for crawlers
The second path keeps the SPA and puts a layer in front of it that detects crawlers and serves them a rendered snapshot. The usual setup is an edge worker (Cloudflare Workers, Fastly Compute or Lambda@Edge) plus a prerender service such as Prerender.io or self-hosted headless Chrome. People pass straight through to the SPA; recognised crawlers get the rendered HTML.
When it's right: you can't rebuild soon and you need a fix in weeks.
What it costs: Prerender.io's Starter plan moved from $49 to $59 a month (30,000 renders) on 15 September 2026, and bigger sites need higher tiers (Prerender.io). Self-hosting headless Chrome avoids the subscription but adds operations work. Edge worker costs are usually small.
What nobody warns you about: you now own a CDN configuration, an edge worker, a prerender contract, cache invalidation and a list of crawler user agents. When you publish, the cache has to know. When a new crawler appears, your worker needs a sensible fallback. When the bot list goes stale, crawlers quietly get the shell again and nothing alerts you.
This was my setup on brandinglab.io. It worked, and it's a defensible workaround. It is still a workaround, and anyone who inspects your stack will see it as one.
Path 3: build-time static generation
The third path renders every route to static HTML at build time and serves those files directly. Current tools include vite-plugin-ssg, React Router's prerender option and Astro's static output. Avoid react-snap: its last release was in December 2018 (npm).
When it's right: the site is mostly static (a product marketing site, a documentation site or a portfolio) and content changes are infrequent.
The catch for publishers: every page is a snapshot from the last build. A new article doesn't exist publicly until you rebuild and deploy, so frequent publishing means build webhooks that someone has to keep working. Dynamic routes such as /articles/[slug] also have to be enumerated at build time, which means your build needs access to your content source.
What you gain: fast pages, cheap hosting, no runtime to manage and full crawler visibility.
What doesn't work
A homemade prerender script in CI. A script that captures every route with headless Chrome and commits the HTML tends to work for a week, then breaks on an auth redirect, a slow API or a hydration error, and stays broken because nobody owns it.
More schema on a blank shell. If the HTML around it is invisible to the crawler, schema injected by the same JavaScript is invisible too.
Relying on llms.txt. An llms.txt file doesn't replace rendering, and Google says Search doesn't use it (Google).
Which fix should I choose?
Choose by how often your content changes and whether the site is a product or a marketing site: frequent publishing points to server rendering, a short-term patch to prerendering, and rarely updated sites to static generation.
Is this mainly a marketing site (pages, articles, case studies)?
├── Yes → Do you want marketers publishing without a developer?
│ ├── Yes → Rebuild on a platform that server-renders by default
│ │ (Webflow, for example).
│ └── No → Path 1: SSR framework.
│
└── No, it's a product or app → Do you publish content weekly or more?
├── Yes → Path 1: SSR framework.
└── No → Can you change the app soon?
├── No, but there's budget → Path 2: edge prerendering.
└── Rarely changes → Path 3: build-time static generation.Two honest checks:
- If AI search is a strategic channel, aim for server-rendered HTML. Every other path carries ongoing operational cost or slows publishing. Workarounds are fine when they buy time for a real fix.
- If you sell AEO, expect prospects to run the curl test on you. I was on Path 2 myself. It passed the test, but I was selling advice my own stack only met through a workaround, and that's a large part of why I moved.
What happened when I ran brandinglab.io as a Lovable SPA?
When brandinglab.io ran on Lovable as a React SPA, AI crawlers received an empty shell; I added a Cloudflare Worker to prerender pages for crawlers, then moved the site to Webflow in November 2026.
My Lovable project predated Lovable's switch to server-side rendering for new projects in May 2026, so it was a client-rendered SPA. When I ran the curl test, the response was the shell: a root div and scripts. The Worker fixed delivery to crawlers by matching their user agents and serving rendered HTML.
Then a second problem appeared one layer up. In July 2026, an AI coding agent installed an unpinned version of react-helmet-async, and every page started presenting the homepage title and no per-page schema. The prerender layer faithfully served that broken head to every crawler. I found it with a ten-second document.title check, and the full investigation is in what an AEO audit actually looks like.
Two layers of workaround (a prerender for the body, a client-side library for the head) meant two places to fail silently. I ran this test on my own site and didn't like the answer, so I moved brandinglab.io to Webflow, which serves finished HTML, titles and schema from the server by default. It's the same approach I use for clients, such as the Opsi Systems Webflow site.
Can schema markup fix an invisible SPA?
Schema markup can't fix an invisible SPA, because schema describes content a crawler can already read and can't supply content it never received. If your JSON-LD is injected by the same JavaScript the crawler skipped, it's missing from the raw response too.
Schema is metadata. Get the HTML right first, then add schema to make that HTML easier for machines to interpret. The same applies to client-side head libraries such as React Helmet: they change the head of the live page after JavaScript runs, so crawlers without JavaScript see the original <head> from the shell. A quick proof is to open View page source on a page where you added schema and search for it. If you can only find it in Inspect, only crawlers that run JavaScript will read it.
Two current facts keep schema in proportion. Google says there's no special schema.org markup needed for AI Overviews or AI Mode (Google). And Google stopped showing FAQ rich results on 7 May 2026, so FAQPage markup now helps comprehension and other engines and no longer earns a search feature (Search Engine Journal).
How do I add JSON-LD to a React SPA?
Add JSON-LD to a React SPA in two places: put site-wide schema (Organization or ProfessionalService, and WebSite) statically in index.html, and render per-page schema (BlogPosting, Service, BreadcrumbList) per route, making sure it also lands in your prerendered or server-rendered HTML.
These are the patterns I used on brandinglab.io while it ran on Vite and React. Copy them, swap in your own values, and test before you deploy.
Site-wide schema in index.html
On a React SPA, the JSON-LD in index.html may be the only thing a non-JavaScript crawler can read, so put your entity data there. It is present before React mounts, on every URL.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfessionalService",
"@id": "https://example.com/#organization",
"name": "Your Company",
"url": "https://example.com",
"logo": "https://example.com/logo.svg",
"description": "One-line description of what you do and for whom.",
"foundingDate": "2012",
"address": { "@type": "PostalAddress", "addressCountry": "IL" },
"areaServed": ["IL", "GB", "US", "ZA"],
"sameAs": [
"https://www.linkedin.com/company/example",
"https://clutch.co/profile/example"
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "https://example.com/#website",
"name": "Your Company",
"url": "https://example.com",
"publisher": { "@id": "https://example.com/#organization" }
}
</script>Leave out the old SearchAction block: Google retired the sitelinks search box on 21 November 2024 and ignores that markup (Search Engine Journal). The @id values let other blocks reference your organisation instead of repeating it.
Per-page schema, with pinned versions
Generate per-page schema from one module, and use a real person as the author. An anonymous "Editorial Team" author teaches machines nothing about who stands behind the content.
// src/lib/schema.ts
const SITE = "https://example.com";
export const articleSchema = (a: {
slug: string; headline: string; description: string;
datePublished: string; dateModified?: string; authorName: string;
}) => ({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: a.headline,
description: a.description,
datePublished: a.datePublished,
dateModified: a.dateModified ?? a.datePublished,
author: { "@type": "Person", name: a.authorName },
publisher: { "@id": `${SITE}/#organization` },
mainEntityOfPage: `${SITE}/articles/${a.slug}`,
});Google's Article documentation accepts Article, NewsArticle and BlogPosting equally (Google). BlogPosting is a sensible default for a blog; it doesn't earn special treatment.
To render it per route, pick one of these and pin it:
- React 19: render
<title>,<meta>and<link>directly in the page component and React places them in the head. Put the JSON-LD in a<script type="application/ld+json">withJSON.stringify(schema). Google reads JSON-LD in the body as well as the head. Render one<title>per page, because React doesn't deduplicate them. - React 18 with react-helmet-async: install an exact version, for example
npm i react-helmet-async@2.0.5 --save-exact. An unpinned install now resolves to v3.0.0, which is what broke the head layer on my site. - The maintained fork:
@dr.pogodin/react-helmet, installed with--save-exactat the version you test.
// React 19: no head library needed
export function ArticleHead({ article }: { article: Article }) {
const schema = articleSchema(article);
return (
<>
<title>{article.title}</title>
<meta name="description" content={article.excerpt} />
<link rel="canonical" href={`https://example.com/articles/${article.slug}`} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
</>
);
}All three still run in the browser. Google supports structured data generated with JavaScript (Google), but crawlers that don't run JavaScript only see per-page schema if your SSR, prerender or static build writes it into the HTML. Check with curl.
Which JSON-LD mistakes do I find most often?
- Two BreadcrumbList blocks on one page, because an auto-injector and the page both add one. Pick one source.
- No
@idreferences, so blocks can't point at each other. - An
og:imageor logo URL that returns 404. Runcurl -Ion every URL in your schema. - Dates that aren't ISO 8601. Use
2026-05-01. - JSON written inline in JSX without
JSON.stringify, which breaks the quoting. - FAQPage markup for questions that aren't visible on the page. Schema must match what people can see.
Validate one page of each type with Google's Rich Results Test and the Schema.org validator, then confirm with curl that non-JavaScript crawlers can see the same blocks.
Where should I start?
Start with the curl test on your three most important pages, because it tells you in five minutes whether you have a rendering problem or a content problem, and the fix is completely different for each.
If you get a shell, that's a build decision about content velocity, engineering capacity and how seriously you take AI search. For many B2B marketing sites, the cleanest answer is a rebuild on a platform that serves finished HTML, which is what I do on the Webflow agency page. If your site already reads fine, the work moves to content, entity clarity and citations, which the AEO services cover.
Run the free AI test for an instant second opinion on what crawlers can read, or book a discovery call if you want to talk through which path fits your site.
