useSchema Hook — Injecting JSON-LD in React and Next.js
Why Runtime JSON-LD Injection Is Necessary in React
React components render into a root DOM node managed by the framework. The document head sits outside this root. You cannot simply write a <script type="application/ld+json"> tag inside a JSX return statement and expect it to land in the head where crawlers look for structured data. React will render it as inline content within the component's DOM tree, not in the head element.
The useEffect hook solves this by executing after the component mounts, giving you direct access to document.head via the standard DOM API. This is the same lifecycle point React uses for any side effect that touches the browser environment — network requests, event listeners, or in this case, injecting structured data that AI crawlers need to find.
The alternative — placing JSON-LD in a static index.html file — fails for multi-page content. Each page on an AEO-optimized site has unique Article schema, unique FAQ schema, and unique Breadcrumb schema. Static placement means one schema for every route. Runtime injection means the correct schema for the current page, generated from the same data that drives the visible content.
The Complete useSchema Implementation
The hook itself is intentionally minimal. Simplicity here is a feature — fewer moving parts means fewer bugs in a critical infrastructure layer. Every page on this guide uses this exact implementation.
import { useEffect } from 'react';
export function useSchema(schema: object) {
useEffect(() => {
// Create a script element for JSON-LD
const script = document.createElement('script');
script.type = 'application/ld+json';
script.text = JSON.stringify(schema);
// Append to document head where crawlers expect it
document.head.appendChild(script);
// Cleanup: remove on unmount to prevent duplicates
// during SPA navigation between routes
return () => {
document.head.removeChild(script);
};
}, []);
}Line by line: useEffect with an empty dependency array runs once when the component mounts. It creates a script element, sets its type to application/ld+json so browsers and crawlers recognize it as structured data, serializes the schema object to a JSON string, and appends it to the head. The return function fires on unmount, removing the script tag so it does not persist when the user navigates away.
The empty dependency array is deliberate. Schema data for a page does not change after mount — the Article headline, FAQ questions, and breadcrumb path are fixed for the life of the component. If you need schema that reacts to state changes (rare in practice), pass the relevant state variable into the dependency array and handle script replacement in the effect body.
Building Schema from Data with Builder Functions
Hard-coding JSON-LD objects in every page component leads to inconsistency and maintenance burden. Builder functions take page-specific inputs and return valid schema objects. The visible content and the structured data stay in sync because they derive from the same source data.
// buildArticleSchema: generates Article JSON-LD from page props
export function buildArticleSchema(options: {
headline: string;
description: string;
slug: string;
datePublished: string;
dateModified: string;
}) {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: options.headline,
description: options.description,
author: {
'@type': 'Person',
name: 'Robert McDonough',
url: 'https://bobmcd.com',
},
datePublished: options.datePublished,
dateModified: options.dateModified,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://yoursite.com/${options.slug}`,
},
};
}
// buildFAQSchema: maps an array of Q&A pairs to FAQPage JSON-LD
interface FAQItem {
question: string;
answer: string;
}
export function buildFAQSchema(faqs: FAQItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
};
}
// buildBreadcrumbSchema: generates BreadcrumbList from path segments
interface BreadcrumbItem {
name: string;
url?: string;
}
export function buildBreadcrumbSchema(items: BreadcrumbItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
...(item.url ? { item: item.url } : {}),
})),
};
}These builders enforce consistency. Every Article schema on the site uses the same author structure. Every FAQ schema follows the same mapping pattern. When you need to update the author URL or add a new field to Article schema, you change one function — not forty page files.
Handling Multiple Schema Types on a Single Page
A properly optimized AEO page carries four distinct schema types simultaneously. Each call to useSchema appends an independent script tag. Google and AI systems parse all of them. Multiple schema blocks are not just valid — they are expected on content-rich pages.
import { useSchema } from '../hooks/useSchema';
import {
personSchema,
buildArticleSchema,
buildFAQSchema,
buildBreadcrumbSchema,
} from '../data/schemaExamples';
// Define schema outside the component for stable references
const articleSchema = buildArticleSchema({
headline: 'Your Page Title Here',
description: 'Your meta description.',
slug: 'your-hub/your-spoke',
datePublished: '2026-03-30',
dateModified: '2026-03-30',
});
const faqSchema = buildFAQSchema(faqs);
const breadcrumbSchema = buildBreadcrumbSchema([
{ name: 'Guide', url: '/guide/aeo' },
{ name: 'Your Hub', url: '/guide/aeo/your-hub' },
{ name: 'Your Spoke' },
]);
export default function YourSpokePage() {
// Each call creates a separate <script type="application/ld+json">
useSchema(personSchema); // Person entity for author
useSchema(articleSchema); // Article with dateModified
useSchema(faqSchema); // FAQPage with Q&A pairs
useSchema(breadcrumbSchema); // BreadcrumbList for hierarchy
// All four script tags are appended to <head>
// All four are removed when this component unmounts
return ( /* your page JSX */ );
}The cleanup behavior is the critical detail for single-page applications. When a user navigates from Page A to Page B, React unmounts Page A's component before mounting Page B. Each useSchema cleanup fires during unmount, removing Page A's four script tags. Then Page B mounts and injects its own four. Without cleanup, navigating through ten pages would leave forty script tags in the head — most of them stale and conflicting.
Next.js and Server-Side Rendering Considerations
The useEffect-based approach works exclusively on the client side. In a client-rendered React app (Create React App, Vite), this is the only option and it works well for crawlers that execute JavaScript. However, not all AI crawlers render JavaScript consistently. GPTBot and PerplexityBot may or may not execute client-side scripts during their crawl, which means useEffect-injected JSON-LD may be invisible to them.
Next.js offers server-side alternatives that embed JSON-LD directly in the initial HTML response, guaranteeing that every crawler — regardless of JavaScript execution capability — receives the structured data. The Pages Router uses the Head component from next/head. The App Router (Next.js 13+) uses the metadata API or a script tag inside the root layout.
import Head from 'next/head';
export default function MyPage() {
const schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Your Title',
dateModified: '2026-03-30',
};
return (
<>
<Head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(schema),
}}
/>
</Head>
{/* Page content */}
</>
);
}| Approach | Framework | SSR Support | JS-Required | Best For |
|---|---|---|---|---|
| useEffect hook | React (CRA, Vite) | No | Yes | Client-rendered SPAs where SSR is not available |
| Next.js Head component | Next.js Pages Router | Yes | No | Pages Router projects needing full crawler coverage |
| Next.js metadata API | Next.js App Router (13+) | Yes | No | App Router projects using the latest Next.js patterns |
| Static HTML script tag | Any (static sites) | N/A | No | Static sites with one schema per HTML file |
| Gatsby React Helmet | Gatsby | Yes (at build) | No | Gatsby projects using the Helmet plugin for head management |
The practical recommendation: if your framework supports server-side rendering, use it for JSON-LD injection. If you are locked into a client-rendered SPA, the useEffect hook is valid but understand that some AI crawlers may miss the schema. Test by checking your pages in Google Rich Results Test (which renders JavaScript) and by inspecting the raw HTML response without JavaScript execution.
Frequently Asked Questions
About the Author