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.
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.
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>
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>
);
}
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.
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.
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>
</>
);
}
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>
</>
);
}
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:
<head> placementThe 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.
After deploying, validate your structured data at https://search.google.com/test/rich-results. Enter your URL and Google will:
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:
"datePublished": "2024-01-15T08:00:00Z"{"@type": "Person", "name": "..."} rather than a bare stringThe correct approach depends on your router:
next/head with dangerouslySetInnerHTML in each page component; use _document.js for sitewide schemas<script type="application/ld+json"> tag in a Server Component; Next.js hoists it to <head> automaticallyValidate 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.