All articles
·by · Manilla Services·Next.jsStructured DataJSON-LDSchema.orgApp Router

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.

Structured data for Next.js App Router: the complete guide

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:

Which schema on which page type

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:

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:

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.