useSchema Hook — Injecting JSON-LD in React and Next.js

RM
Robert McDonough·Web Content Architect & AEO Systems Builder
TITLEuseSchema Hook — JSON-LD Injection for React and Next.js | AEO Resource Guide
DESCComplete implementation of the useSchema custom hook for injecting JSON-LD structured data in React and Next.js applications. Includes builder functions and SSR considerations.
QUERIESuseSchema hook React JSON-LD·How to inject JSON-LD in React·Next.js structured data implementation·JSON-LD useEffect hook
UPDATED
Direct Answer
The useSchema hook is a custom React hook that injects JSON-LD structured data into the document head at runtime using useEffect. It creates a script element with type application/ld+json, stringifies the schema object, appends it to the head, and removes it on component unmount to prevent duplicates during SPA navigation. This is the standard pattern for AEO-optimized React applications.

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.

useSchema.ts — the complete hooktsx
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.

Schema builder functions — Article, FAQ, and Breadcrumbtsx
// 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.

A page component with four useSchema callstsx
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.

Next.js Pages Router — JSON-LD via Head componenttsx
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 */}
    </>
  );
}
JSON-LD injection approaches compared by framework and crawler compatibility
ApproachFrameworkSSR SupportJS-RequiredBest For
useEffect hookReact (CRA, Vite)NoYesClient-rendered SPAs where SSR is not available
Next.js Head componentNext.js Pages RouterYesNoPages Router projects needing full crawler coverage
Next.js metadata APINext.js App Router (13+)YesNoApp Router projects using the latest Next.js patterns
Static HTML script tagAny (static sites)N/ANoStatic sites with one schema per HTML file
Gatsby React HelmetGatsbyYes (at build)NoGatsby 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

RM

Robert McDonough