Structured data for Next.js App Router: the complete guide
How to add schema.org JSON-LD correctly in Next.js App Router: the centralized @graph pattern, @id conventions, one node per page type, and how to avoid the duplicates that break rich results.

In short: in Next.js App Router you add structured data as JSON-LD, injected through a <script type="application/ld+json"> component, ideally as a single @graph per page with stable @ids. The most common bug isn't missing schema, it's duplicate schema — the same type emitted twice from different sources. Below, the pattern that scales.
Why it matters in App Router
App Router renders most pages on the server (Server Components), which is ideal for structured data: JSON-LD lands in the initial HTML, exactly what the crawler sees, without waiting for JavaScript. You don't need external libraries — a simple component that serializes an object is enough.
The JsonLd component
The cleanest approach is a minimal reusable component:
export default function JsonLd({ data }: { data: object }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
dangerouslySetInnerHTML sounds dangerous, but here it's correct: you serialize an object you control, not user input. Use it in any Server Component, including layout.tsx for global schema and page.tsx for page-specific schema.
The centralized @graph pattern
The classic mistake is scattering JSON-LD nodes across multiple components — one from the layout, one from the page, one from a breadcrumb component, one from an FAQ. The result: the same page emits three separate <script>s, with overlapping nodes.
The solution: a single @graph per page, with all nodes in one array, each with a unique @id:
<JsonLd data={{
'@context': 'https://schema.org',
'@graph': [
{ '@type': 'WebPage', '@id': `${url}#webpage`, /* ... */ },
{ '@type': 'BreadcrumbList', '@id': `${url}#breadcrumb`, /* ... */ },
{ '@type': 'FAQPage', '@id': `${url}#faq`, /* ... */ },
],
}} />
Nodes reference each other by @id (e.g. WebPage.breadcrumb → { '@id': '...#breadcrumb' }), forming a connected graph that Google reads as one coherent whole.
The @id convention
Use stable @ids based on URL + fragment: ${url}#webpage, ${url}#breadcrumb, ${baseUrl}/#org for the global entity. This way:
- each node has a unique, referable identity,
- the organization entity (
/#org) is defined once globally and referenced from every page, - you avoid collisions between nodes of the same type on different URLs.
Which schema on which page type
- Home:
WebSite+Organization(global) +WebPage. - Indexes (blog, projects):
CollectionPagewithhasPart. - Blog post:
BlogPosting+WebPage(envelope) +BreadcrumbList, optionallyHowTo. - Service page:
Service+WebPage+BreadcrumbList+FAQPage. - About:
AboutPage; Contact:ContactPage— both subtypes ofWebPage. - Author:
Person(author) +Organization(publisher) — standard pattern.
The duplicate trap
This is the bug that shows up most often and is invisible in code — you only see it in Rich Results Test. Common sources:
- a global
siteGraphin the layout that emits aWebPageon every page, - the page emitting its own explicit
WebPage→ twoWebPages, - multi-type like
['WebPage', 'CollectionPage']PLUS a second separateCollectionPagenode, - components (Breadcrumb, FaqList) that auto-emit their own schema coexisting with the centralized graph.
I wrote a whole case study about this — how to fix duplicate JSON-LD — with the four sources and the fix for each.
How to validate
npx tsc --noEmit checks types, but NOT the rendered graph. Structured data validates at runtime:
- Rich Results Test (search.google.com/test/rich-results) on the live URL — confirms each type appears once.
- Schema Markup Validator (validator.schema.org) for pure schema.org validation.
- Search Console → Enhancements for errors at scale, after re-crawl (5-14 days).
The rule: after every new page with schema, run it through Rich Results Test. Correct types in code don't guarantee a correctly rendered @graph.
Summary
Structured data in App Router is simple if you follow three rules: a single @graph per page, stable URL-based @ids, and runtime validation not just compile-time. The rest is mapping the schema type to the page type.
If you want an audit of your existing structured data with fixes applied directly in code, that's what we do in the SEO/AI audit. For monitoring and continuous improvements, it's part of Continuous SEO.