React JSON-LD: Add Schema Markup That Google Actually Reads
Last Updated: February 25, 2026 · 16 min read
Adding JSON-LD in a React app is not simply about writing the code. The problem is that React, by default, runs in the browser. If your schema markup is injected into the DOM by JavaScript after the page loads, Google may not process it — or it will process it hours or days later when Googlebot runs its slower JavaScript rendering queue. This guide covers how to add structured data in React correctly, with specific solutions for Next.js App Router, Next.js Pages Router, and client-side React apps (Create React App, Vite).
The core problem with React and SEO
Googlebot crawls pages in two queues: fast (raw HTML, no JS) and slow (full JavaScript render). The fast queue processes pages within hours of crawling. The slow queue can take days or weeks. If your schema only exists after JavaScript runs, it sits in the slow queue. Your rich results may be delayed significantly or missed entirely if the page changes between crawls.
The solution: always render JSON-LD server-side so it is in the raw HTML response. When Google hits your page in the fast queue, it sees the schema immediately.
Method 1: Next.js App Router (recommended)
Next.js App Router renders Server Components on the server by default. JSON-LD added inside a Server Component is included in the initial HTML — exactly what you want. This is the cleanest and most reliable approach.
In a page.tsx file (which is a Server Component by default), add the schema block before or inside your returned JSX:
// app/products/[slug]/page.tsx — Server Component (no 'use client')
import type { Metadata } from 'next';
// Fetch product data server-side
async function getProduct(slug: string) {
const res = await fetch(`https://api.yoursite.com/products/${slug}`);
return res.json();
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug);
const productSchema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.imageUrl,
description: product.description,
offers: {
'@type': 'Offer',
price: product.price.toString(),
priceCurrency: 'USD',
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
};
return (
<>
{/* This renders in the initial HTML — Google sees it instantly */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }}
/>
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
</main>
</>
);
}For sitewide schema (Organization, WebSite) that should appear on every page, add it to app/layout.tsx. Since layout.tsx is a Server Component, the schema ends up in the HTML of every page:
// app/layout.tsx — sitewide schema on every page
const orgSchema = {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': 'https://yoursite.com/#organization',
name: 'Your Company',
url: 'https://yoursite.com',
logo: { '@type': 'ImageObject', url: 'https://yoursite.com/logo.png' },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }}
/>
</head>
<body>{children}</body>
</html>
);
}Method 2: Next.js Pages Router
In the Pages Router, pages are server-rendered by default when you use getServerSideProps or getStaticProps. Add your JSON-LD inside the Head component from next/head:
// pages/products/[slug].tsx
import Head from 'next/head';
import type { GetStaticProps } from 'next';
export default function ProductPage({ product }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
};
return (
<>
<Head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
</Head>
<main>
<h1>{product.name}</h1>
</main>
</>
);
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const product = await fetchProduct(params.slug as string);
return { props: { product } };
};Because getStaticProps runs at build time (or on-demand with ISR), the product data — and therefore the schema — is baked into the HTML. Google sees it in the fast crawl queue.
Method 3: Create React App or Vite (client-side SPA)
A fully client-side React app (Create React App, Vite without SSR) presents a real challenge. The server only returns a near-empty HTML shell — Google has to run JavaScript to see any content or schema. There are two paths here:
Option A — Add static schema to the HTML template (for sitewide schema only)
For Organization and WebSite schema that never changes, you can hardcode JSON-LD directly into the HTML template that CRA/Vite serves. For CRA, this is public/index.html. For Vite, it is index.html in the project root.
<!-- public/index.html -->
<head>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Your Company",
"url": "https://yoursite.com"
}
</script>
</head>Option B — Migrate to a framework with SSR
For page-specific schema (Product, Article, Recipe) that depends on dynamic data, your only reliable option is server-side rendering. The simplest migration path from CRA is to move to Next.js with the Pages Router — most CRA React code works unchanged. Vite users can add SSR via vite-plugin-ssr (now Vike) or migrate to Remix.
Dynamic schema from API data — patterns that work
In Next.js, you can fetch live data and build schema from it at request time using Server Components or getServerSideProps. Here is a real-world pattern for a blog article page that pulls author details from an API:
// app/blog/[slug]/page.tsx
interface Post {
title: string;
body: string;
publishedAt: string;
author: { name: string; bio: string; profileUrl: string };
coverImage: string;
}
async function getPost(slug: string): Promise<Post> {
const res = await fetch(`https://cms.yoursite.com/posts/${slug}`, {
next: { revalidate: 3600 }, // ISR: re-fetch every hour
});
if (!res.ok) notFound();
return res.json();
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
const schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.publishedAt,
image: post.coverImage,
author: {
'@type': 'Person',
name: post.author.name,
description: post.author.bio,
url: post.author.profileUrl,
},
};
return (
<>
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
</>
);
}The react-schemaorg library
The react-schemaorg npm package provides TypeScript-typed JSON-LD components for React. It does not solve the SSR problem — it still renders to a script tag — but it gives you TypeScript autocomplete and type checking for schema properties:
// npm install react-schemaorg schema-dts
import { JsonLd } from 'react-schemaorg';
import type { Product } from 'schema-dts';
// Use inside a Server Component — typescript will catch property errors
<JsonLd<Product> item={{
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
// TypeScript error if you add an invalid property
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
},
}} />How to verify the schema is in the initial HTML
Do not assume it is working — always check. Here is the exact way to confirm:
Right-click on your page in the browser → View Page Source. This shows the raw HTML as the server sent it, before any JavaScript ran.
Press Ctrl+F (or Cmd+F on Mac) and search for "application/ld+json". Your JSON-LD block should appear in the raw source.
If the script tag is NOT in View Page Source but IS visible in DevTools (Inspect), your schema is client-side injected. You need to fix this by moving to a Server Component or getStaticProps.
Validate with SchemaValidator.org by entering your live URL. This fetches your page like a crawler and parses the schema from the raw HTML.
Validate Your React App's Schema
Enter your page URL. We fetch it like a crawler and report every schema error — including whether your JSON-LD is server-rendered or client-injected.
Check Schema Now →