Skip to content

    Is your site invisible to AI? Get a free AEO Audit →

    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 in index.html so it's present before React mounts.
    • Per-route schema (BlogPosting, Service, FAQPage, BreadcrumbList, CreativeWork) goes via react-helmet-async so 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:

    • Organization or ProfessionalService (the entity behind the site)
    • WebSite (with SearchAction for 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:

    • BlogPosting on each article
    • Service on each service page
    • CreativeWork on each case study
    • FAQPage on FAQ pages and case-study FAQ blocks
    • BreadcrumbList on 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:

    Run both on:

    • Homepage (expect: Organization or ProfessionalService + WebSite + BreadcrumbList)
    • One article (expect: + BlogPosting)
    • One service page (expect: + Service)
    • The FAQ page (expect: + FAQPage)
    • One case study (expect: + CreativeWork or your domain-appropriate type)

    Common mistakes we see weekly

    1. Two BreadcrumbList blocks per page. Happens when an auto-injector adds breadcrumbs and the page also passes breadcrumbSchema(...) manually. Pick one source of truth.
    2. Article instead of BlogPosting. BlogPosting is more specific and triggers richer treatment. Use it for any blog content.
    3. Generic Organization instead of ProfessionalService (or LocalBusiness). If you have an address and a price range, the more specific type accepts richer fields and signals more confidence.
    4. No @id references. Without @id, schema blocks can't reference each other (e.g., BlogPosting.publisher referencing the site's Organization). Use fragment URLs.
    5. og:image URL that 404s. We've seen this on dozens of sites — the OG image is referenced in index.html but the file doesn't exist. curl -I every URL in your schema before shipping.
    6. datePublished in the future or as a non-ISO string. Both fail validation silently. Use ISO 8601: 2026-05-01.
    7. Inline JSON in JSX without JSON.stringify. React will escape quotes and break the JSON. Always serialize with JSON.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.

    Request your free AEO Audit →

    Key Takeaways

    • React SPAs ship empty <div id="root"></div> HTML; without JSON-LD in the initial payload, AI crawlers see nothing semantic.
    • Put site-wide schema (Organization/ProfessionalService, WebSite) statically in index.html so it is present before React mounts.
    • Inject per-route schema (BlogPosting, Service, FAQPage, BreadcrumbList, CreativeWork) via react-helmet-async.
    • Prefer specific subtypes: BlogPosting over Article, ProfessionalService over generic Organization.
    • Validate every page type at Google Rich Results Test and validator.schema.org before shipping.

    Want to discuss this topic?

    Need a B2B Webflow agency that ships in 4 weeks with AEO baked in? See our Webflow services →

    Start a Conversation