TL;DR
- React SPAs (Vite, CRA, Lovable, Bolt, v0) ship empty
<div id="root"></div>HTML. Without JSON-LD in the initial payload, AI crawlers see nothing semantic. - Site-wide schema (
Organization/ProfessionalService,WebSite) goes statically inindex.htmlso it's present before React mounts. - Per-route schema (
BlogPosting,Service,FAQPage,BreadcrumbList,CreativeWork) goes viareact-helmet-asyncso each URL ships its own structured data. - Validate everything at https://search.google.com/test/rich-results before declaring victory.
- The patterns below are what we ship on every B2B Vite + React SPA we touch. Copy them, change the values, deploy.
This is the tactical companion to our piece on why React SPAs are invisible to AI search. Read that for the why. Read this for the what.
Why React SPAs need JSON-LD even more than server-rendered sites
JSON-LD is structured data — a JSON block in your HTML that tells search engines and AI crawlers what your page is about in a machine-readable format. For server-rendered sites, JSON-LD is a bonus: crawlers can understand most of the page from the rendered HTML alone. For React SPAs, JSON-LD is not optional — it's often the only thing crawlers can read.
When a crawler hits a Vite + React SPA without executing JavaScript, the body is <div id="root"></div>. Empty. The only signal-carrying content available is whatever's in the static <head>. Title and meta description help. JSON-LD helps far more, because it gives crawlers structured, typed information they can map directly into knowledge graphs.
Even crawlers that do execute JavaScript benefit. Schema present in the initial payload is treated as more authoritative than schema injected during hydration.
Where JSON-LD goes in a React SPA
Two locations. Use both.
Static, in index.html
For schema that's true site-wide and never changes per route. Goes in the <head> of index.html (the Vite entry HTML). Present before React executes. Visible to every crawler.
Use for:
OrganizationorProfessionalService(the entity behind the site)WebSite(withSearchActionfor sitelinks search box)
Dynamic, via react-helmet-async
For schema that changes per route. Mounted in React; updated whenever the user navigates. Visible to JS-capable crawlers; falls back to the static schema for non-JS crawlers.
Use for:
BlogPostingon each articleServiceon each service pageCreativeWorkon each case studyFAQPageon FAQ pages and case-study FAQ blocksBreadcrumbListon every page
The patterns we ship
Pattern 1 — Static Organization (or ProfessionalService) in index.html
ProfessionalService is a more specific subtype of Organization that extends LocalBusiness. If you're a B2B services firm with an address and a price range, prefer it.
<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 value prop.",
"foundingDate": "2018",
"address": {"@type": "PostalAddress", "addressCountry": "IL"},
"priceRange": "$$$",
"areaServed": ["IL", "US", "GB", "EU", "Worldwide"],
"knowsAbout": ["Topic 1", "Topic 2", "Topic 3"],
"sameAs": [
"https://www.linkedin.com/company/example",
"https://clutch.co/profile/example"
],
"hasOfferCatalog": {
"@type": "OfferCatalog",
"name": "Services",
"itemListElement": [
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Service A"}},
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Service B"}}
]
}
}
</script>
The @id matters — it's the canonical identifier other schema blocks reference (e.g., "publisher": {"@id": "https://example.com#organization"}). Use a fragment URL like #organization.
Pattern 2 — Static WebSite in index.html
<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"},
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://example.com/search?q={search_term_string}"
},
"query-input": "required name=search_term_string"
}
}
</script>
The SearchAction enables Google's sitelinks search box for branded queries. Drop it if you don't have an internal search.
Pattern 3 — A reusable schema generator module
Don't paste JSON-LD inline on every page. Centralise it. A single src/lib/schema.ts exports functions for each page type:
const SITE_URL = "https://example.com";
const LOGO_URL = `${SITE_URL}/logo.svg`;
export function articleSchema(a: {
slug: string;
headline: string;
description: string;
image?: string;
datePublished: string;
dateModified?: string;
authorName?: string;
}) {
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: a.headline,
description: a.description,
image: a.image,
datePublished: a.datePublished,
dateModified: a.dateModified ?? a.datePublished,
author: { "@type": "Person", name: a.authorName ?? "Editorial Team" },
publisher: {
"@type": "Organization",
name: "Your Company",
logo: { "@type": "ImageObject", url: LOGO_URL }
},
mainEntityOfPage: { "@type": "WebPage", "@id": `${SITE_URL}/articles/${a.slug}` }
};
}
export function serviceSchema(s: {
name: string;
description: string;
slug: string;
serviceType: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Service",
name: s.name,
description: s.description,
provider: { "@id": `${SITE_URL}#organization` },
serviceType: s.serviceType,
areaServed: ["IL", "US", "GB", "EU", "Worldwide"],
url: `${SITE_URL}/${s.slug}`
};
}
export function faqPageSchema(items: { question: string; answer: string }[]) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map(i => ({
"@type": "Question",
name: i.question,
acceptedAnswer: { "@type": "Answer", text: i.answer }
}))
};
}
export function breadcrumbSchema(crumbs: { name: string; url: string }[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: crumbs.map((c, i) => ({
"@type": "ListItem",
position: i + 1,
name: c.name,
item: c.url
}))
};
}
Use BlogPosting, not Article, for blog posts. BlogPosting is a more specific subtype and triggers richer SERP and AI treatment.
Pattern 4 — Inject per-route schema with react-helmet-async
import { Helmet } from "react-helmet-async";
import { articleSchema, breadcrumbSchema } from "@/lib/schema";
export function ArticlePage({ article }) {
const schemas = [
articleSchema({
slug: article.slug,
headline: article.title,
description: article.excerpt,
image: article.ogImage,
datePublished: article.publishedAt,
dateModified: article.updatedAt,
authorName: article.author
}),
breadcrumbSchema([
{ name: "Home", url: "https://example.com" },
{ name: "Articles", url: "https://example.com/articles" },
{ name: article.title, url: `https://example.com/articles/${article.slug}` }
])
];
return (
<>
<Helmet>
<title>{article.title} · Your Company</title>
<meta name="description" content={article.excerpt} />
<link rel="canonical" href={`https://example.com/articles/${article.slug}`} />
{schemas.map((s, i) => (
<script key={i} type="application/ld+json">{JSON.stringify(s)}</script>
))}
</Helmet>
{/* page body */}
</>
);
}
Wrap your app in <HelmetProvider> once at the root (src/main.tsx):
import { HelmetProvider } from "react-helmet-async";
createRoot(document.getElementById("root")!).render(
<HelmetProvider>
<App />
</HelmetProvider>
);
Validate everything
Two free tools, both worth running on every page type:
- Google Rich Results Test — https://search.google.com/test/rich-results — confirms validity and shows which rich results your schema is eligible for.
- Schema.org Validator — https://validator.schema.org/ — stricter; catches edge cases Google's tool ignores.
Run both on:
- Homepage (expect:
OrganizationorProfessionalService+WebSite+BreadcrumbList) - One article (expect: +
BlogPosting) - One service page (expect: +
Service) - The FAQ page (expect: +
FAQPage) - One case study (expect: +
CreativeWorkor your domain-appropriate type)
Common mistakes we see weekly
- Two
BreadcrumbListblocks per page. Happens when an auto-injector adds breadcrumbs and the page also passesbreadcrumbSchema(...)manually. Pick one source of truth. Articleinstead ofBlogPosting.BlogPostingis more specific and triggers richer treatment. Use it for any blog content.- Generic
Organizationinstead ofProfessionalService(orLocalBusiness). If you have an address and a price range, the more specific type accepts richer fields and signals more confidence. - No
@idreferences. Without@id, schema blocks can't reference each other (e.g.,BlogPosting.publisherreferencing the site'sOrganization). Use fragment URLs. og:imageURL that 404s. We've seen this on dozens of sites — the OG image is referenced inindex.htmlbut the file doesn't exist.curl -Ievery URL in your schema before shipping.datePublishedin the future or as a non-ISO string. Both fail validation silently. Use ISO 8601:2026-05-01.- Inline JSON in JSX without
JSON.stringify. React will escape quotes and break the JSON. Always serialize withJSON.stringify(schema).
What this gets you
Schema markup is necessary but not sufficient for AEO. It establishes brand entity in AI knowledge graphs, makes your pages eligible for rich results in Google, and gives JS-capable AI crawlers structured signal to work with. It does not, by itself, fix the SPA invisibility problem — for that you also need either pre-rendering (build-time SSG via tools like react-snap), server-side rendering, or a markdown-based discovery file (llms.txt and llms-full.txt).
For the full strategy — schema plus rendering plus discovery — see why your SPA is invisible to AI search and how to fix it.
Get a free AEO audit of your B2B site
We'll audit your site's schema, rendering, and AI-crawler accessibility — for free. Twenty-minute report, prioritized fix list, no commitment.