JavaScript SEO & Structured Data: How to Render JSON-LD Correctly in React and Next.js

Last Updated: February 25, 2026 · 13 min read

Googlebot can render JavaScript — but it does so in a second wave, sometimes days after the initial crawl. If your JSON-LD is only injected after JavaScript executes on the client, Google may index your pages without structured data for extended periods, costing you rich results. This guide explains the rendering pipeline, the right patterns for each framework, and how to test whether your schema is actually being seen.

1. How Googlebot Renders Pages: The Two-Wave Model

Wave 1
Immediate

Googlebot fetches raw HTML. Reads anything in the initial HTML response — including server-rendered JSON-LD. Fast and reliable.

Wave 2
Hours to days later

Googlebot returns to execute JavaScript. Discovers client-injected content. Delay varies by crawl budget and page priority.

🚨 The core risk

If your JSON-LD is only present after JavaScript runs (client-side injection), Google may index the page during Wave 1 without the structured data. You will not see it in GSC rich result reports until Wave 2 — which may be days or weeks later. During that window, no rich results.

2. Rule: Always Server-Render Your JSON-LD

The fix is simple: ensure <script type="application/ld+json"> is present in the initial HTML response, not injected after hydration. Here is how to do it correctly in the major frameworks:

✅ Next.js (App Router) — Correct Pattern

Use a Server Component. The JSON-LD is rendered on the server and included in the HTML stream.

// app/products/[slug]/page.tsx
// This is a Server Component — no 'use client'

import { getProduct } from '@/lib/products';

export default async function ProductPage({
  params
}: { params: { slug: string } }) {
  const product = await getProduct(params.slug);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    'name': product.name,
    'offers': {
      '@type': 'Offer',
      'price': product.price,
      'priceCurrency': 'USD',
      'availability': 'https://schema.org/InStock',
    },
  };

  return (
    <>
      {/* Rendered in initial HTML — Googlebot Wave 1 sees this */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <main>
        <h1>{product.name}</h1>
        {/* ... rest of page */}
      </main>
    </>
  );
}

❌ Anti-pattern: Client-side injection (useEffect)

This means the JSON-LD is only added after React hydrates and runs the effect — not in the initial HTML.

// ❌ DO NOT DO THIS
'use client';
import { useEffect } from 'react';

export default function ProductPage({ product }) {
  useEffect(() => {
    // This runs client-side only — Googlebot Wave 1 misses it
    const script = document.createElement('script');
    script.type = 'application/ld+json';
    script.text = JSON.stringify({ '@type': 'Product', ... });
    document.head.appendChild(script);
  }, []);
}

✅ Next.js Pages Router (getServerSideProps / getStaticProps)

// pages/products/[slug].tsx
export default function ProductPage({ product }) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    'name': product.name,
    'offers': { '@type': 'Offer', 'price': product.price },
  };

  return (
    <>
      <Head>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
        />
      </Head>
      <main>...</main>
    </>
  );
}

export async function getStaticProps({ params }) {
  const product = await getProduct(params.slug);
  return { props: { product } };
}

✅ React with SSR (Express / custom server)

// server.js — inject schema before sending HTML
app.get('/products/:slug', async (req, res) => {
  const product = await getProduct(req.params.slug);
  
  const jsonLd = JSON.stringify({
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
  });

  const html = renderToString(<App product={product} />);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <script type="application/ld+json">${jsonLd}</script>
      </head>
      <body><div id="root">${html}</div></body>
    </html>
  `);
});

3. How to Test If Google Sees Your Schema

🔍 View Page Source (Ctrl+U)

Check if the <script type="application/ld+json"> block is present in the raw HTML. If it only appears in DevTools Elements panel but not source, it is client-injected.

🔍 Google Rich Results Test

Use the URL test — Google's tool renders the page and shows what structured data it found post-render. Compare to source to spot the gap.

🔍 Google Search Console URL Inspection

Click "Test Live URL" → "View Tested Page" → "More Info" → look at detected structured data after full rendering.

🔍 Our validator

Paste the URL — our tool fetches the page server-side and parses the raw HTML, showing only what Googlebot Wave 1 would see.

4. Crawl Budget & JavaScript: The Hidden Cost

Rendering JavaScript is significantly more resource-intensive for Googlebot than reading plain HTML. Sites with large JavaScript bundles, slow Time to First Byte (TTFB), or complex hydration may find Googlebot deprioritises their Wave 2 rendering — meaning client-injected schema takes even longer to be discovered. Server-rendering your structured data removes this variable entirely.

Frequently Asked Questions

Does Googlebot fully support JavaScript in 2026?

Googlebot uses an evergreen Chromium-based renderer and supports modern JavaScript including ES modules, async/await, and dynamic imports. However, it renders pages in a queue (Wave 2) that can lag by hours to weeks for lower-priority pages. Server-rendering structured data eliminates this dependency entirely.

Can I add JSON-LD dynamically via a useEffect hook?

Technically it works — Google will eventually see it after Wave 2 rendering. But it creates unnecessary risk: a delay of days before schema is indexed, potential crawl budget waste, and a maintenance footprint where schema depends on client runtime. Always server-render JSON-LD from a Server Component or getServerSideProps/getStaticProps.

What is the fastest way to fix client-injected schema in Next.js?

Move the JSON-LD script tag from a Client Component (marked “use client”) into the nearest Server Component parent. In App Router, the page.tsx file is a Server Component by default. Fetch the data you need (product info, article metadata) in the page function and inline the JSON.stringify call directly in the JSX.

Does Next.js App Router guarantee server-side rendering of JSON-LD?

Yes — any Server Component renders at request time (SSR) or build time (SSG). As long as your JSON-LD script tag is in a Server Component (no “use client” directive), it will appear in the initial HTML response that Googlebot fetches in Wave 1.

What about Gatsby or other static site generators?

Static site generators (Gatsby, Astro, Hugo, Eleventy) generate HTML at build time, so all content including JSON-LD is present in the static HTML files. Googlebot Wave 1 sees everything. This is the most reliable setup. Dynamic route parameters require server functions or ISR for per-page schema data.

I have a React SPA (no SSR). What are my options?

Pure client-side SPAs are the worst case for structured data. Options: (1) Add SSR (Next.js migration); (2) Use a service like Prerender.io to serve pre-rendered HTML to Googlebot; (3) Use React’s renderToString in an Express server to SSR at least the head section for bots. Option 1 is the most robust long-term.

Does structured data in a Shadow DOM get indexed?

No. Google does not index content inside Shadow DOM elements. Ensure your JSON-LD script tags are in the main document, not inside a Web Component’s shadow root. This is a rare edge case but trips up teams migrating to Web Components.

How do I confirm my schema is in the Wave 1 HTML (not just post-JS)?

Use Ctrl+U (View Source) in your browser — this shows the raw HTML before JavaScript runs. If your JSON-LD script tag does not appear here, it is client-injected. Alternatively, use “curl -A Googlebot https://example.com/page” in terminal to fetch the server response directly as Googlebot would see it in Wave 1.

Check If Your Schema Is in the HTML

Our validator fetches pages server-side — enter any URL to see exactly what Googlebot Wave 1 reads.

Test Your URL →