How to Add JSON-LD Structured Data in Next.js (App Router & Pages Router)

How to Add JSON-LD Structured Data in Next.js (App Router & Pages Router)

JSON-LD is the recommended way to add schema.org structured data to web pages. Google parses it to generate rich results — star ratings, FAQs, breadcrumbs, article metadata. The question is where exactly to put it in a Next.js app without breaking SSR, hydration, or Content Security Policy headers.

This post shows working patterns for both the Pages Router (Next.js 12 and earlier) and the App Router (Next.js 13/14), with real examples for the three most useful schema types: Article, FAQPage, and BreadcrumbList.


Why JSON-LD in Next.js Is Tricky

JSON-LD must be injected as a <script type="application/ld+json"> tag inside <head>. That sounds simple, but Next.js controls the document head through its own abstractions — next/head in Pages Router, the metadata API or generateMetadata in App Router — and neither of these accepts raw <script> tags in the obvious way.

The hydration constraint is the core problem. If the server renders a <script> tag inside <head> and the client then re-renders that same component, React will try to reconcile the DOM. If the JSON-LD content changes between server and client (e.g., because you derive it from client-only state), you get a hydration mismatch warning. The fix is always to derive JSON-LD from data available at render time on the server.


Pages Router: Per-Page JSON-LD with next/head

In the Pages Router, the simplest and most common pattern is to use next/head directly in the page component. This works for per-page structured data where the schema changes per URL.

// pages/blog/[slug].jsx
import Head from 'next/head';

export default function BlogPost({ post }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": post.title,
    "description": post.excerpt,
    "datePublished": post.publishedAt,
    "dateModified": post.updatedAt,
    "author": {
      "@type": "Person",
      "name": post.authorName
    },
    "publisher": {
      "@type": "Organization",
      "name": "My Site",
      "url": "https://example.com"
    },
    "mainEntityOfPage": {
      "@type": "WebPage",
      "@id": `https://example.com/blog/${post.slug}`
    }
  };

  return (
    <>
      <Head>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
        />
      </Head>
      <article>
        <h1>{post.title}</h1>
        <p>{post.content}</p>
      </article>
    </>
  );
}

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

Note the use of dangerouslySetInnerHTML — this is required because React would otherwise escape the JSON content. The next/head component deduplicates tags by key prop; if you need multiple JSON-LD blocks, give each a unique key:

<Head>
  <script
    key="jsonld-article"
    type="application/ld+json"
    dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
  />
  <script
    key="jsonld-breadcrumb"
    type="application/ld+json"
    dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
  />
</Head>

Pages Router: Sitewide JSON-LD via _document.js

For structured data that belongs on every page — like an Organization schema — inject it in pages/_document.js. This file controls the outer HTML document and renders only on the server, so there are no hydration concerns.

// pages/_document.js
import { Html, Head, Main, NextScript } from 'next/document';

const organizationSchema = {
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "My Company",
  "url": "https://example.com",
  "logo": "https://example.com/logo.png",
  "sameAs": [
    "https://twitter.com/mycompany",
    "https://linkedin.com/company/mycompany"
  ]
};

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(organizationSchema)
          }}
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

App Router: JSON-LD in layout.tsx and page.tsx

The App Router (Next.js 13+) replaces next/head with the metadata export API. That API handles <title>, <meta>, and Open Graph tags, but it does not support arbitrary <script> tags. For JSON-LD you must inject the script tag directly in your JSX.

The recommended pattern from Next.js documentation is to render a <script> tag directly inside the Server Component — either in layout.tsx for sitewide data or in page.tsx for per-page data:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

interface Props {
  params: { slug: string };
}

async function fetchPost(slug: string) {
  // fetch from CMS, database, etc.
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 }
  });
  if (!res.ok) return null;
  return res.json();
}

export default async function BlogPostPage({ params }: Props) {
  const post = await fetchPost(params.slug);
  if (!post) notFound();

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": post.title,
    "description": post.excerpt,
    "datePublished": post.publishedAt,
    "dateModified": post.updatedAt ?? post.publishedAt,
    "author": {
      "@type": "Person",
      "name": post.author.name,
      "url": `https://example.com/authors/${post.author.slug}`
    },
    "publisher": {
      "@type": "Organization",
      "name": "My Site",
      "url": "https://example.com"
    }
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        <h1>{post.title}</h1>
        <p>{post.content}</p>
      </article>
    </>
  );
}

Next.js 13+ hoists <script> tags from Server Components into <head> automatically when they appear at the top level of a page or layout return. This is the simplest approach and it works without next/script.

App Router: Using next/script for JSON-LD

Alternatively, you can use next/script with strategy="beforeInteractive". This is more explicit but behaves identically for JSON-LD (which is non-executable anyway):

// app/layout.tsx
import Script from 'next/script';

const organizationSchema = {
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "My Site",
  "url": "https://example.com"
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Script
          id="schema-org"
          type="application/ld+json"
          strategy="beforeInteractive"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(organizationSchema)
          }}
        />
        {children}
      </body>
    </html>
  );
}

The id prop is required by next/script when using dangerouslySetInnerHTML. Without it, Next.js will throw a build error.


Schema Examples

FAQPage Schema

FAQPage markup can produce accordion-style rich results in Google Search, showing individual questions and answers directly in the SERP.

// app/faq/page.tsx
const faqs = [
  {
    question: "What is structured data?",
    answer: "Structured data is a standardized format for providing information about a page and classifying its content."
  },
  {
    question: "Does JSON-LD affect page speed?",
    answer: "Minimal impact. JSON-LD is a small inline script parsed by search engine crawlers, not executed by the browser."
  },
  {
    question: "Can I use multiple schema types on one page?",
    answer: "Yes. You can have multiple JSON-LD script blocks or combine them into a @graph array."
  }
];

export default function FAQPage() {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "FAQPage",
    "mainEntity": faqs.map(faq => ({
      "@type": "Question",
      "name": faq.question,
      "acceptedAnswer": {
        "@type": "Answer",
        "text": faq.answer
      }
    }))
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <h1>Frequently Asked Questions</h1>
      <dl>
        {faqs.map(faq => (
          <div key={faq.question}>
            <dt><strong>{faq.question}</strong></dt>
            <dd>{faq.answer}</dd>
          </div>
        ))}
      </dl>
    </>
  );
}

BreadcrumbList Schema

Breadcrumb rich results show the page path directly in Google Search results. The structured data must mirror the visible breadcrumb navigation.

// components/Breadcrumbs.tsx
interface Crumb {
  name: string;
  url: string;
}

interface BreadcrumbsProps {
  crumbs: Crumb[];
}

export function Breadcrumbs({ crumbs }: BreadcrumbsProps) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": crumbs.map((crumb, index) => ({
      "@type": "ListItem",
      "position": index + 1,
      "name": crumb.name,
      "item": crumb.url
    }))
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <nav aria-label="Breadcrumb">
        <ol>
          {crumbs.map((crumb, i) => (
            <li key={crumb.url}>
              {i < crumbs.length - 1 ? (
                <a href={crumb.url}>{crumb.name}</a>
              ) : (
                <span aria-current="page">{crumb.name}</span>
              )}
            </li>
          ))}
        </ol>
      </nav>
    </>
  );
}

Usage in a page:

// app/blog/[slug]/page.tsx
import { Breadcrumbs } from '@/components/Breadcrumbs';

export default function BlogPostPage({ params }: Props) {
  const crumbs = [
    { name: "Home", url: "https://example.com" },
    { name: "Blog", url: "https://example.com/blog" },
    { name: post.title, url: `https://example.com/blog/${params.slug}` }
  ];

  return (
    <>
      <Breadcrumbs crumbs={crumbs} />
      <article>...</article>
    </>
  );
}

Common Mistake: JSON-LD in body Instead of head

Google's documentation states JSON-LD can appear in either <head> or <body>, and in practice Google crawls it from both locations. However, placing it in <head> is strongly preferred because:

  • Bing and other search engines have historically been less reliable about parsing body JSON-LD
  • Validators like Google's Rich Results Test and schema.org validator expect <head> placement
  • Browser extensions and SEO auditing tools may miss body-level JSON-LD

The App Router's direct <script> tag in Server Components is hoisted to <head> automatically by Next.js. The next/script approach with strategy="beforeInteractive" also ends up in <head>. Where people go wrong is placing JSON-LD inside a Client Component that renders in the body — if you need dynamic JSON-LD data, derive it server-side and pass it as props.


How to Test with Google Rich Results Test

After deploying, validate your structured data at https://search.google.com/test/rich-results. Enter your URL and Google will:

  • Render the page as Googlebot (including JavaScript execution)
  • Extract all JSON-LD, microdata, and RDFa
  • Report which rich result types are eligible and any validation errors

During development, use the URL inspection field with a publicly accessible URL. For localhost testing, use https://validator.schema.org/ — it accepts raw HTML input so you can paste your page source directly.

Common validation errors and fixes:

  • "Missing field 'datePublished'" — add an ISO 8601 date string: "datePublished": "2024-01-15T08:00:00Z"
  • "The value of field 'author' must be a valid URL or a schema.org type" — wrap author in {"@type": "Person", "name": "..."} rather than a bare string
  • "Logo must be a valid URL" — use the full absolute URL including protocol
  • JSON parse error — run your JSON-LD through https://jsonlint.com/ to find syntax errors (trailing commas are a common culprit)

Summary

The correct approach depends on your router:

  • Pages Router — use next/head with dangerouslySetInnerHTML in each page component; use _document.js for sitewide schemas
  • App Router — render a bare <script type="application/ld+json"> tag in a Server Component; Next.js hoists it to <head> automatically
  • Both routers — keep JSON-LD derivation server-side to avoid hydration mismatches; use absolute URLs throughout

Validate with Google Rich Results Test after every schema change. Rich results take days to weeks to appear in SERPs after Google re-crawls your pages, so test early in the development cycle rather than after launch.