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
FAQPage can describe visible questions and answers, but Google retired FAQ rich results in May 2026. The pattern below illustrates vocabulary and rendering, not eligibility for the former expanding results. See Google’s documentation updates.
// 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 the schema generator to prepare supported example markup, then validate the actual published output:
For more on schema types and which ones produce rich results, see How to Add Schema Markup to Your Next.js App Router Site.
Keep a sample of published JSON-LD and compare it after template changes. RankCrab’s paid audit workspace remains prelaunch.
This article is part of the SEO for Next.js cluster. Related reading: