Next.js SEO: adding JSON-LD structured data with schema.org schemas
Jun 10, 2026 · 7 min read
Structured data is one of the few SEO levers that still has a clear, measurable payoff: rich results in Google Search — star ratings, FAQ dropdowns, breadcrumb trails — that lift click-through rate without changing your rank. If you've built Next.js App Router projects but haven't wired up JSON-LD yet, this guide walks through exactly how to do it, which schemas matter most, and what mistakes to avoid.
Why structured data matters for organic traffic
Google uses JSON-LD (and Microdata, but ignore that) to understand the type of content on a page, not just the words. A page that says "How long does it take to build a headless site?" reads as an FAQ to a crawler only if you mark it up correctly. Without markup, Google might render it as a plain blue link. With a valid FAQPage schema, it can expand into an accordion directly on the SERP — often doubling the vertical space your result occupies.
The payoff is asymmetric. Adding structured data takes maybe two hours per schema type on a Next.js site. The risk of a penalty for getting it wrong is essentially zero (Google ignores invalid markup; it doesn't punish it). The upside is a richer snippet that can move CTR from 3% to 6%+ on informational queries.
This guide covers four schemas that cover most content sites:
BlogPosting— individual blog or article pagesBreadcrumbList— navigation trail shown in the SERP URLFAQPage— Q&A content, surfaces accordion snippetsPerson— author or about pages, useful for E-E-A-T signals
How JSON-LD works in Next.js App Router
JSON-LD is a <script type="application/ld+json"> tag in your page's <head>. In the App Router, the right place for it is inside the page or layout component itself — not in generateMetadata. The Metadata API handles <title> and <meta> tags; JSON-LD is a separate <script> tag you render directly.
Create a tiny reusable component:
// components/json-ld.tsx
import type { Thing, WithContext } from 'schema-dts'
interface JsonLdProps {
schema: WithContext<Thing>
}
export function JsonLd({ schema }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
)
}Install schema-dts for TypeScript types: npm i schema-dts. It gives you typed autocompletion for every schema.org type — saves a lot of typo-debugging.
Then in any Server Component page, import and render it:
// app/blog/[slug]/page.tsx
import { JsonLd } from '@/components/json-ld'
import type { WithContext, BlogPosting, BreadcrumbList } from 'schema-dts'
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
// Fetch your post data however you do it — Sanity, MDX, etc.
const post = await getPost(params.slug)
const blogPostingSchema: WithContext<BlogPosting> = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
datePublished: post.publishedAt,
dateModified: post.updatedAt ?? post.publishedAt,
author: {
'@type': 'Person',
name: post.author.name,
url: `https://yourdomain.com/about`,
},
image: post.coverImage,
url: `https://yourdomain.com/blog/${post.slug}`,
publisher: {
'@type': 'Organization',
name: 'Your Site Name',
logo: {
'@type': 'ImageObject',
url: 'https://yourdomain.com/logo.png',
},
},
}
const breadcrumbSchema: WithContext<BreadcrumbList> = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://yourdomain.com' },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://yourdomain.com/blog' },
{ '@type': 'ListItem', position: 3, name: post.title, item: `https://yourdomain.com/blog/${post.slug}` },
],
}
return (
<>
<JsonLd schema={blogPostingSchema} />
<JsonLd schema={breadcrumbSchema} />
<article>
<h1>{post.title}</h1>
{/* rest of your page */}
</article>
</>
)
}You can render multiple <JsonLd> components on the same page. Google supports multiple JSON-LD blocks.
FAQPage schema
The FAQPage schema is worth extra attention because the rich result it produces — a collapsed accordion — is one of the most visible on mobile SERPs. It's best used on pages that literally contain questions and answers, not stuffed onto every page.
The rule Google enforces: each acceptedAnswer must be fully visible on the page. If you hide answers behind a JS-only accordion that Google can't render, the rich result will be rejected.
// Inside a FAQ page component
const faqSchema: WithContext<FAQPage> = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'How long does a headless CMS migration take?',
acceptedAnswer: {
'@type': 'Answer',
text: 'A typical WordPress to headless migration takes 6–12 weeks depending on content complexity and integrations.',
},
},
{
'@type': 'Question',
name: 'What is the cost of a Sanity CMS project?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Most mid-size Sanity CMS projects cost between $8,000 and $25,000 depending on schema complexity and custom studio work.',
},
},
],
}Person schema for E-E-A-T
Google's E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness) guidelines weight author signals heavily for YMYL and informational content. A Person schema on your author or about page connects your name to a URL that Google can associate with other content you've authored.
Add this to /app/about/page.tsx:
const personSchema: WithContext<Person> = {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Nayan Kyada',
url: 'https://yourdomain.com/about',
jobTitle: 'Next.js + Sanity CMS Developer',
worksFor: {
'@type': 'Organization',
name: 'Freelance / Your Studio Name',
},
sameAs: [
'https://github.com/yourusername',
'https://twitter.com/yourusername',
'https://linkedin.com/in/yourusername',
],
}The sameAs array is important — it links your Person entity to authoritative external profiles, which strengthens the knowledge graph connection.
Validating with Rich Results Test
After deploying, test every schema type at search.google.com/test/rich-results. Paste your page URL or raw HTML. The tool shows:
- Which schema types were detected
- Whether each type is eligible for a rich result
- Any errors (required fields missing) or warnings (recommended fields missing)
Common errors I see in the wild:
Missing datePublished on BlogPosting. Google requires it. If your CMS doesn't store a publish date, use your git commit date or Date.now() at build time — anything beats omitting it.
image as a bare string instead of an ImageObject. Both are technically valid schema.org, but Google's validator is stricter. Use an ImageObject with explicit url, width, and height to avoid warnings.
FAQ answers only in JavaScript. If your FAQ accordion hides text in a JS-only state, Googlebot may not see it during indexing. Render the answer text in the HTML, even if it's visually collapsed via CSS.
Breadcrumb item URLs that 404. Every item in a BreadcrumbList must resolve. If your /blog route returns a 404 or redirects, the breadcrumb enrichment gets dropped.
Putting JSON-LD in generateMetadata. The Metadata API doesn't support script tags — it only outputs recognised meta fields. JSON-LD added there gets silently ignored. It must be a rendered <script> tag in your JSX.
Keeping schemas in sync with content
The maintenance burden of structured data is low if you generate schemas from the same data object you use to render the page — which is exactly what the code above does. The schema reads from post.title, post.publishedAt, etc., so as long as your CMS data is accurate, the schema stays accurate.
The one thing to watch: absolute URLs. Schema.org properties like url, item, and image.url must be fully qualified (https://yourdomain.com/...), not relative paths. Create a small siteUrl constant and prefix consistently rather than hard-coding the domain in every schema.
Once your schemas are live and passing the Rich Results Test, submit affected URLs to Google Search Console via the URL Inspection tool to request re-indexing. Rich results typically appear within days for crawled pages, not weeks.
Structured data won't move you from page 3 to page 1 — that's a content and authority problem. But for pages already in positions 4–15, a rich result can meaningfully lift CTR, and that's a real traffic gain for maybe a day's implementation work.
Related posts
All posts →How I build a custom Sanity document actions publishing workflow
Jun 08, 2026 · 7 min read
Step-by-step guide to building custom Sanity document actions: approval gates, Slack notifications on publish, and scheduled releases via the Releases API.
How I write advanced GROQ queries with joins, coalesce, and array flattening in Sanity
Jun 07, 2026 · 6 min read
Deep dive into advanced GROQ queries in Sanity: inline joins, coalesce, conditional projections, and array flattening with real payload sizes measured.
Next.js partial prerendering PPR in production: what actually works
Jun 06, 2026 · 5 min read
An honest look at Next.js partial prerendering PPR in production — what it solves, where it breaks, and which page patterns benefit most.