Next.js Structured Data: The Type-Safe JSON-LD Pattern
App Router has no built-in JSON-LD support. Here's the type-safe pattern that emits valid schema from a server component.
The App Router's Metadata API covers title, description, Open Graph, canonical URLs, and robots — but it doesn't cover structured data. JSON-LD lives in a <script type="application/ld+json"> tag, and you have to put it there yourself.
The pattern: a Server Component that renders a <Script> tag with your JSON-LD payload. This keeps structured data out of client bundles, runs at render time with full access to server-side data, and gives you TypeScript type safety over every schema property.
Why the Metadata API Doesn't Cover JSON-LD
The Metadata type in next doesn't have a jsonLd field. This is a deliberate omission — the Metadata API is designed for <head> tags that affect crawling, social sharing, and browser behavior. JSON-LD is schema markup for search engines, and its structure varies too widely across schema types to fit in a typed metadata object.
The right primitive is the <Script> component from next/script:
import Script from 'next/script'
export default function Page() {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'My Article Title',
}
return (
<>
<Script
id="json-ld"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* page content */}</article>
</>
)
}
In a Server Component, this renders the <script> tag in the HTML on the server — exactly what you want.
Adding Type Safety with schema-dts
The schema-dts package provides TypeScript types for every schema.org type. Install it:
npm install schema-dts
Then build a typed helper:
// lib/json-ld.ts
import type { Thing, WithContext } from 'schema-dts'
export function jsonLd<T extends Thing>(data: WithContext<T>): string {
return JSON.stringify(data)
}
This helper accepts any WithContext<T> value and returns a JSON string. The TypeScript type ensures you include required fields and get autocomplete on optional ones.
Article Schema
For blog posts and editorial content:
// app/blog/[slug]/page.tsx
import Script from 'next/script'
import { jsonLd } from '@/lib/json-ld'
import type { Article } from 'schema-dts'
type Props = {
params: Promise<{ slug: string }>
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
const articleSchema = jsonLd<Article>({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: `https://yourdomain.com/blog/${slug}/opengraph-image`,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author,
url: `https://yourdomain.com/authors/${post.authorSlug}`,
},
publisher: {
'@type': 'Organization',
name: 'Your Site Name',
logo: {
'@type': 'ImageObject',
url: 'https://yourdomain.com/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://yourdomain.com/blog/${slug}`,
},
})
return (
<>
<Script
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: articleSchema }}
/>
<article>{/* render post.content */}</article>
</>
)
}
FAQ Schema
FAQ schema can trigger rich results in Google — an expandable list of questions and answers directly in the SERP. Use it on pages that have a genuine FAQ section:
// components/faq-section.tsx
import Script from 'next/script'
import { jsonLd } from '@/lib/json-ld'
import type { FAQPage } from 'schema-dts'
type FAQItem = {
question: string
answer: string
}
type Props = {
items: FAQItem[]
}
export function FAQSection({ items }: Props) {
const faqSchema = jsonLd<FAQPage>({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
})
return (
<>
<Script
id="faq-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: faqSchema }}
/>
<section>
<h2>Frequently Asked Questions</h2>
{items.map((item) => (
<details key={item.question}>
<summary>{item.question}</summary>
<p>{item.answer}</p>
</details>
))}
</section>
</>
)
}
BreadcrumbList Schema
Breadcrumb schema helps Google display your site hierarchy in search results:
// components/breadcrumbs.tsx
import Script from 'next/script'
import Link from 'next/link'
import { jsonLd } from '@/lib/json-ld'
import type { BreadcrumbList } from 'schema-dts'
type Crumb = {
name: string
href: string
}
type Props = {
items: Crumb[]
}
export function Breadcrumbs({ items }: Props) {
const breadcrumbSchema = jsonLd<BreadcrumbList>({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `https://yourdomain.com${item.href}`,
})),
})
return (
<>
<Script
id="breadcrumb-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: breadcrumbSchema }}
/>
<nav aria-label="Breadcrumb">
<ol>
{items.map((item, index) => (
<li key={item.href}>
{index < items.length - 1 ? (
<Link href={item.href}>{item.name}</Link>
) : (
<span aria-current="page">{item.name}</span>
)}
</li>
))}
</ol>
</nav>
</>
)
}
Multiple Schemas on One Page
Some pages need more than one schema type — a blog post might have Article schema, BreadcrumbList schema, and FAQ schema. You can emit multiple <Script> tags, one per schema:
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return (
<>
<Script
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLd<Article>({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
// ...
}) }}
/>
<Script
id="breadcrumb-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLd<BreadcrumbList>({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
// ...
}) }}
/>
<article>{/* content */}</article>
</>
)
}
Each <Script> needs a unique id prop. Next.js uses this to deduplicate scripts.
Generating Schema from Your CMS
If your content comes from a CMS, generate schema dynamically from the CMS response:
// lib/schema-builders.ts
import type { Article, WithContext } from 'schema-dts'
export function buildArticleSchema(post: {
title: string
excerpt: string
slug: string
publishedAt: string
updatedAt: string
authorName: string
authorSlug: string
}): WithContext<Article> {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: `https://yourdomain.com/blog/${post.slug}/opengraph-image`,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.authorName,
url: `https://yourdomain.com/authors/${post.authorSlug}`,
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://yourdomain.com/blog/${post.slug}`,
},
}
}
This pattern keeps schema construction testable and reusable across page types.
Validating Your Schema
Use RankCrab's schema generator to build and validate schema markup before or after implementation:
For more on schema types and which ones produce rich results, see How to Add Schema Markup to Your Next.js App Router Site.
For a full audit of your structured data — including required fields, type mismatches, and missing schema on high-value pages — run the 80-check audit.
This article is part of the SEO for Next.js cluster. Related reading: