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 reads it to understand what a page is about and, for supported types, to show rich results such as breadcrumbs or article details. The practical question is where to put it in a Next.js app so it ships in the server HTML, survives hydration, and cannot be used for script injection.
This post shows working patterns for both the Pages Router (still supported in current Next.js) and the App Router (Next.js 13 and later), with examples for Article, BreadcrumbList, and FAQPage. The snippets below were built with next build on Next.js 16.3.6 and the served HTML was parsed to confirm each JSON-LD tag appears once and contains valid JSON.
Why JSON-LD in Next.js Is Tricky
JSON-LD is a <script type="application/ld+json"> tag. Google accepts it in either <head> or <body>. The confusion in Next.js comes from its head abstractions: next/head in the Pages Router accepts script tags, but the App Router's metadata export and generateMetadata have no field for JSON-LD, so you render the tag yourself in JSX.
Hydration adds one more constraint. The server renders the script tag, then React hydrates the same component in the browser. If the JSON-LD differs between the two (say it depends on client-only state), React reports a hydration mismatch. Build the object from data available on the server at render time.
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).replace(/</g, '\\u003c')
}}
/>
</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. It is required because React would otherwise HTML-escape the JSON text. It also means nothing escapes it for you, so see the escaping section below before you put CMS or user data into the object. 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).replace(/</g, '\\u003c') }}
/>
<script
key="jsonld-breadcrumb"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema).replace(/</g, '\\u003c') }}
/>
</Head>
Pages Router: Sitewide JSON-LD via _document.js
For structured data that belongs on every page, such as 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).replace(/</g, '\\u003c')
}}
/>
</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 Next.js JSON-LD guide recommends rendering a native <script> tag in a Server Component, either in layout.tsx for sitewide data or in page.tsx for per-page data. Since Next.js 15, params is a Promise, so await it:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
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,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await fetchPost(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).replace(/</g, '\\u003c')
}}
/>
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
</>
);
}
Next.js does not move this tag into <head>. In the test build it stayed in <body>, exactly where the component rendered it, and the server HTML contained it once. That is fine: Google reads JSON-LD from the body, and the tag is in the initial HTML, so crawlers that do not run JavaScript still see it.
One trap from older tutorials: interface Props { params: { slug: string } } with params.slug still passed next build type checking on 16.3.6, but at runtime params.slug rendered as undefined. Always await params.
Escaping: Replace < Before It Reaches the HTML
JSON.stringify does not escape </script>. If any field comes from a CMS or user input, a value containing it ends the script tag early, which breaks the JSON and opens an XSS hole. The Next.js guide recommends replacing < with its unicode escape \u003c, which is what every snippet in this post does. With a test headline of Hello </script><b>x, the unescaped version produced JSON that failed to parse (Unterminated string), while the escaped version rendered:
"headline":"Hello \u003c/script>\u003cb>x"
That parses back to the original string, so search engines see the same text.
Typing JSON-LD with schema-dts
For type checking, the Next.js guide points to the community package schema-dts. This compiled cleanly in the test app with schema-dts 2.0.0:
import type { Organization, WithContext } from 'schema-dts';
const organizationSchema: WithContext<Organization> = {
"@context": "https://schema.org",
"@type": "Organization",
"name": "My Site",
"url": "https://example.com"
};
App Router: Why Not next/script?
Some tutorials wrap JSON-LD in next/script with strategy="beforeInteractive" in the root layout. It builds, but the output is different from a plain tag. On Next.js 16.3.6 the server HTML contained no application/ld+json tag at all. The data was shipped inside an inline bootstrap script instead:
self.__next_s=self.__next_s||[]).push([0,{"type":"application/ld+json","children":"{\"@context\":\"https://schema.org\",\"@type\":\"Organization\",\"name\":\"My Site\",\"url\":\"https://example.com\"}","id":"schema-org"}])
The real tag only exists after client JavaScript runs. Google says it can read JSON-LD injected by JavaScript, but tools and crawlers that read raw HTML will miss it. The Next.js guide says it directly: next/script is optimized for loading and executing JavaScript, and a native <script> tag is the right choice for JSON-LD. For a sitewide schema, put the native tag in app/layout.tsx:
// app/layout.tsx
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
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(organizationSchema).replace(/</g, '\\u003c')
}}
/>
{children}
</body>
</html>
);
}
You may also read that next/script throws a build error without an id when using dangerouslySetInnerHTML. It does not: a page with an inline <Script> and no id built and served with status 200. Next.js recommends an id on inline scripts so it can track them, but that is moot for JSON-LD if you use a native tag.
Schema Examples
FAQPage Schema
Do not add FAQPage markup expecting a rich result. Google limited FAQ rich results to well-known, authoritative government and health sites in August 2023, then added a deprecation notice in May 2026 and, on June 15, 2026, removed the documentation because the FAQ rich result is no longer shown in Google Search (Google Search Central updates). HowTo rich results were already dropped in September 2023. FAQPage is still valid schema.org vocabulary, so the markup below is harmless and other consumers may read it, but it will not change how the page looks in Google. The pattern itself is the same as for any other type:
// 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).replace(/</g, '\\u003c')
}}
/>
<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 in Google Search results. Google's breadcrumb documentation currently lists the feature as available on desktop. Each ListItem needs position and name, plus item for every entry except the last. Keep the markup in sync with the breadcrumb navigation users actually see.
// 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).replace(/</g, '\\u003c')
}}
/>
<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 async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug); // your data fetcher
const crumbs = [
{ name: "Home", url: "https://example.com" },
{ name: "Blog", url: "https://example.com/blog" },
{ name: post.title, url: `https://example.com/blog/${slug}` }
];
return (
<>
<Breadcrumbs crumbs={crumbs} />
<article>...</article>
</>
);
}
Does JSON-LD Have to Be in the head?
No. Google's structured data introduction describes JSON-LD as a script tag in the <head> or <body>, and the Rich Results Test does not require head placement. That matters because the recommended App Router pattern puts the tag in the body. In the Pages Router, next/head and _document.js did place the tags in <head> in the test build.
What matters more:
- Ship it in the server HTML. Google can read JSON-LD injected by JavaScript, but a Client Component that builds JSON-LD from browser-only state risks hydration mismatches and hides the data from anything that does not render JavaScript. Derive it on the server and pass data down as props.
- Match the visible page. Google says not to add structured data about information that is not visible to the user, even if it is accurate. Headlines, dates, breadcrumbs and FAQ answers in the JSON-LD should appear on the page.
- Render each block once. Placing the same schema in both a layout and a page duplicates it on every route under that layout.
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
The URL option needs a publicly accessible URL. For localhost, both the Rich Results Test (Code tab) and the Schema Markup Validator accept pasted HTML, so you can copy the page source from curl http://localhost:3000/your-page.
Things worth checking when a result is missing or flagged:
- Dates: Google's Article documentation has no required properties, but
datePublishedanddateModifiedare recommended and should be ISO 8601, for example"2024-01-15T08:00:00Z". - Author: use a
PersonorOrganizationobject with aname, and ideally aurlfor a page that identifies the author, rather than a bare string. - URLs: use absolute URLs including the protocol for
logo,image,itemand@id. - JSON parse errors: usually a trailing comma in hand-written JSON-LD, or an unescaped
</script>inside a string value. Paste the payload into a JSON validator to find it.
Summary
Short version, per router:
- Pages Router: use
next/headwithdangerouslySetInnerHTMLin each page component, and_document.jsfor sitewide schemas. - App Router: render a native
<script type="application/ld+json">tag in a Server Component, notnext/script. It stays in the body, which Google accepts. Awaitparams. - Both routers: escape
<as\u003c, build JSON-LD on the server, keep it consistent with visible content, and use absolute URLs.
Validate with the Rich Results Test after every schema change. Changes only show up after Google recrawls the page, and valid markup makes a page eligible for a rich result without guaranteeing one.