JSON-LD for React SPAs: A Practical Schema Markup Guide
BrandingLab
Editorial
JSON-LD for React SPAs: A Practical Schema Markup Guide
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.
Frequently asked questions
Do I need both static and dynamic JSON-LD?
Yes. Static schema in index.html (Organization/ProfessionalService, WebSite) gives crawlers identity and site context on every URL before React mounts. Dynamic per-route schema (BlogPosting, Service, FAQPage, BreadcrumbList) describes the specific page. Crawlers that don't execute JavaScript still get the site-wide entity; crawlers that do execute it get the per-page detail.
Will react-helmet-async work for AI crawlers that don't run JavaScript?
No — that's the catch. react-helmet-async injects schema after React mounts, so non-rendering crawlers (most AI bots, classic Googlebot fallback) won't see it. For those, you need prerendering, SSR, or a markdown discovery file (llms.txt/llms-full.txt). Use Helmet for the JS-capable crawlers; use one of the other three to cover the rest.
Which schema type should I use for a blog post — Article or BlogPosting?
BlogPosting. It's a subtype of Article and signals editorial blog content specifically. Google and most AI engines prefer the more specific subtype. Same logic for ProfessionalService over Organization, LocalBusiness over Organization for location-based businesses.
Do I need to validate every page or just the templates?
Validate every distinct page type — homepage, article template, service page, case study, FAQ — once. You don't need to revalidate every individual blog post unless you change the template. Run Google Rich Results Test and validator.schema.org on one example of each type before shipping, and after any template change.
Will Google penalize me for having JSON-LD that doesn't match visible content?
Yes. Schema must accurately describe what's on the page. Don't fabricate AggregateRating values, don't mark up FAQs that aren't visible, don't claim authorship that isn't real. Mismatched schema is treated as spam and can trigger manual actions.
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.