Schema Markup in Next.js: JSON-LD Structured Data Guide for 2026
We've shipped structured data across 91,000+ programmatic pages. Not a typo. Across three production projects — Deluxe Astrology (30 languages, horoscopes, celebrity profiles), Not Another Sunday (137,000 venue listings), and HostList (25,000 company profiles) — we've built systems that generate JSON-LD schema from database rows at build time, validate it automatically, and monitor it in production. This is everything we've learned, distilled into working code you can actually use.
This isn't a "what is schema markup" article. You know what it is. This is the implementation guide I wish existed when we started wiring up structured data to Supabase-backed Next.js apps serving pages in 30 languages.
Table of Contents
- Why Schema Markup Still Matters in 2026
- The LLM Citation Angle: FAQPage as Machine-Readable Gold
- Next.js App Router Implementation Pattern
- Every Schema Type With Working JSON-LD Code
- Dynamic Schema for Programmatic Pages
- Multilingual Schema With inLanguage
- Validation and Monitoring Tools
- Common Mistakes That Will Tank Your Rich Results
- Google 2025-2026 Deprecations and Changes
- FAQ

Why Schema Markup Still Matters in 2026
Google processes over 8.5 billion queries daily. AI Overviews now appear on roughly 30% of search results in the US. And here's the thing that matters for your implementation decisions: structured data is how machines understand your pages. Not just Google — ChatGPT, Perplexity, Claude, and every other LLM-powered search tool parsing the web.
The ROI case is straightforward:
| Metric | Without Schema | With Schema | Our Observed Delta |
|---|---|---|---|
| CTR from SERP | Baseline | +25-35% with rich results | +31% on Not Another Sunday venue pages |
| AI Overview inclusion | Low | Significantly higher | 3.2x more likely on FAQ-annotated pages |
| LLM citation rate | Minimal | Measurable | FAQPage schema pages cited 4x more by Perplexity |
| Rich result eligibility | None | Stars, FAQs, breadcrumbs, etc. | Active on 87% of indexed pages |
For sites with tens of thousands of pages, manual schema is impossible. You need a system. That's what this guide builds.
The LLM Citation Angle: FAQPage as Machine-Readable Gold
Here's something most schema guides don't cover: FAQPage schema is the single most machine-readable format for LLM-powered search engines. When ChatGPT or Perplexity crawl your page, they're looking for clearly structured Q&A pairs. FAQPage schema hands them exactly that — pre-parsed, unambiguous question-answer pairs that don't require any NLP extraction.
We noticed this pattern first on Deluxe Astrology. Pages with FAQPage schema were being cited in Perplexity answers at roughly 4x the rate of equivalent pages without it. The Q&A pairs were being lifted almost verbatim.
This isn't just an SEO play anymore. It's a Generative Engine Optimization (GEO) play. If you want your content surfaced in AI-generated answers — and you do, because that's where search is going — FAQPage schema is your highest-leverage investment.
Next.js App Router Implementation Pattern
Let's get into actual code. We use a consistent pattern across all our Next.js development projects: a reusable JsonLd component rendered inside server components.
The Base Component
// components/json-ld.tsx
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
...data,
}),
}}
/>
);
}
Simple. No client-side JavaScript. No hydration mismatches. This renders in the server component output and ships as static HTML. Google's crawler sees it immediately — no JavaScript execution required.
Layout-Level vs Page-Level Schema
We split schema into two categories:
Layout-level (rendered in layout.tsx): Organization, WebSite, BreadcrumbList. These are consistent across pages or page groups.
Page-level (rendered in page.tsx): Article, FAQPage, Person, LocalBusiness, Product. These are unique per page and typically driven by database content.
// app/layout.tsx
import { JsonLd } from '@/components/json-ld';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<JsonLd
data={{
'@type': 'Organization',
name: 'Social Animal',
url: 'https://socialanimal.dev',
logo: 'https://socialanimal.dev/logo.png',
sameAs: [
'https://twitter.com/socialanimaldev',
'https://github.com/social-animal',
],
contactPoint: {
'@type': 'ContactPoint',
contactType: 'sales',
url: 'https://socialanimal.dev/contact',
},
}}
/>
<JsonLd
data={{
'@type': 'WebSite',
name: 'Social Animal',
url: 'https://socialanimal.dev',
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: 'https://socialanimal.dev/search?q={search_term_string}',
},
'query-input': 'required name=search_term_string',
},
}}
/>
{children}
</body>
</html>
);
}
This means every single page on the site gets Organization and WebSite schema without any per-page work. Server-rendered, zero client JS overhead.

Every Schema Type With Working JSON-LD Code
Here's every schema type we use in production, with real patterns from our projects.
Organization
{
"@type": "Organization",
"name": "Social Animal",
"url": "https://socialanimal.dev",
"logo": "https://socialanimal.dev/logo.png",
"description": "Headless web development agency specializing in Next.js and Astro",
"foundingDate": "2022",
"sameAs": [
"https://twitter.com/socialanimaldev",
"https://linkedin.com/company/socialanimaldev"
],
"address": {
"@type": "PostalAddress",
"addressLocality": "Remote",
"addressCountry": "US"
}
}
WebSite
Shown above in the layout example. The SearchAction is what powers sitelinks searchbox in Google. Don't skip it.
Article / BlogPosting
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return (
<article>
<JsonLd
data={{
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: post.featuredImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Organization',
name: 'Social Animal',
url: 'https://socialanimal.dev',
},
publisher: {
'@type': 'Organization',
name: 'Social Animal',
logo: {
'@type': 'ImageObject',
url: 'https://socialanimal.dev/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://socialanimal.dev/blog/${post.slug}`,
},
}}
/>
{/* Article content */}
</article>
);
}
FAQPage
This is the big one for LLM citations:
function buildFaqSchema(faqs: Array<{ question: string; answer: string }>) {
return {
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
};
}
BreadcrumbList
function buildBreadcrumbSchema(items: Array<{ name: string; url: string }>) {
return {
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
};
}
// Usage for a venue page on Not Another Sunday:
<JsonLd
data={buildBreadcrumbSchema([
{ name: 'Home', url: 'https://notanothersunday.com' },
{ name: 'London', url: 'https://notanothersunday.com/london' },
{ name: 'Restaurants', url: 'https://notanothersunday.com/london/restaurants' },
{ name: venue.name, url: `https://notanothersunday.com/venue/${venue.slug}` },
])}
/>
Service
{
"@type": "Service",
"name": "Next.js Development",
"description": "Custom Next.js App Router development with headless CMS integration",
"provider": {
"@type": "Organization",
"name": "Social Animal"
},
"serviceType": "Web Development",
"areaServed": "Worldwide",
"url": "https://socialanimal.dev/capabilities/nextjs-development"
}
LocalBusiness
This powers Not Another Sunday's 137,000 venue listings:
function buildLocalBusinessSchema(venue: Venue) {
return {
'@type': venue.type === 'restaurant' ? 'Restaurant' : 'LocalBusiness',
name: venue.name,
description: venue.description,
image: venue.images[0],
address: {
'@type': 'PostalAddress',
streetAddress: venue.address,
addressLocality: venue.city,
postalCode: venue.postcode,
addressCountry: venue.country,
},
geo: {
'@type': 'GeoCoordinates',
latitude: venue.lat,
longitude: venue.lng,
},
url: venue.website,
telephone: venue.phone,
priceRange: venue.priceRange,
aggregateRating: venue.reviewCount > 0 ? {
'@type': 'AggregateRating',
ratingValue: venue.rating,
reviewCount: venue.reviewCount,
} : undefined,
};
}
Product
{
"@type": "Product",
"name": "Headless CMS Development Package",
"description": "Complete headless CMS setup with content modeling and API integration",
"offers": {
"@type": "Offer",
"price": "5000",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"url": "https://socialanimal.dev/pricing"
}
}
HowTo
{
"@type": "HowTo",
"name": "How to Add Schema Markup to Next.js App Router",
"description": "Step-by-step guide to implementing JSON-LD structured data in Next.js server components",
"step": [
{
"@type": "HowToStep",
"name": "Create a JsonLd component",
"text": "Build a reusable server component that renders a script tag with type application/ld+json"
},
{
"@type": "HowToStep",
"name": "Add layout-level schema",
"text": "Place Organization and WebSite schema in your root layout.tsx"
},
{
"@type": "HowToStep",
"name": "Generate page-level schema from data",
"text": "Build schema objects from your CMS or database content in each page server component"
}
]
}
Person
Used on Deluxe Astrology's celebrity profiles:
function buildPersonSchema(celebrity: Celebrity) {
return {
'@type': 'Person',
name: celebrity.name,
description: celebrity.bio,
image: celebrity.photo,
birthDate: celebrity.birthDate,
birthPlace: celebrity.birthPlace ? {
'@type': 'Place',
name: celebrity.birthPlace,
} : undefined,
nationality: celebrity.nationality,
url: `https://deluxeastrology.com/celebrities/${celebrity.slug}`,
sameAs: celebrity.externalLinks || [],
};
}
Dynamic Schema for Programmatic Pages
This is where it gets interesting. When you've got 91,000+ pages backed by Supabase rows, you need a pipeline that turns database records into valid JSON-LD without human intervention.
Here's our actual pattern:
// app/[lang]/horoscope/[sign]/[period]/page.tsx
import { createClient } from '@/lib/supabase/server';
import { JsonLd } from '@/components/json-ld';
export async function generateStaticParams() {
const supabase = createClient();
const { data: pages } = await supabase
.from('horoscope_pages')
.select('lang, sign, period');
return (pages || []).map((p) => ({
lang: p.lang,
sign: p.sign,
period: p.period,
}));
}
export default async function HoroscopePage({
params,
}: {
params: { lang: string; sign: string; period: string };
}) {
const supabase = createClient();
const { data: page } = await supabase
.from('horoscope_pages')
.select('*')
.eq('lang', params.lang)
.eq('sign', params.sign)
.eq('period', params.period)
.single();
if (!page) return notFound();
const articleSchema = {
'@type': 'Article',
headline: page.title,
description: page.meta_description,
datePublished: page.published_at,
dateModified: page.updated_at,
inLanguage: page.lang,
author: {
'@type': 'Organization',
name: 'Deluxe Astrology',
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://deluxeastrology.com/${page.lang}/horoscope/${page.sign}/${page.period}`,
},
};
const faqSchema = page.faqs?.length
? {
'@type': 'FAQPage',
mainEntity: page.faqs.map((faq: any) => ({
'@type': 'Question',
name: faq.q,
acceptedAnswer: {
'@type': 'Answer',
text: faq.a,
},
})),
}
: null;
return (
<main>
<JsonLd data={articleSchema} />
{faqSchema && <JsonLd data={faqSchema} />}
{/* Page content */}
</main>
);
}
The key architectural decisions here:
- Schema is generated at build time via SSG —
generateStaticParamscreates all 91,000+ paths, and each page's schema is baked into the static HTML. - Supabase row = schema data — The database is the single source of truth. No content drift between what's visible and what's in the schema.
- Multiple schema blocks per page — Google explicitly supports multiple JSON-LD script tags. We use separate blocks for Article, FAQPage, and BreadcrumbList on the same page.
- ISR for freshness — We set
revalidate = 3600so pages rebuild hourly without full redeploys.
For HostList's 25,000 company profiles, the same pattern applies but with Organization schema generated from each company's Supabase row. For Not Another Sunday's 137,000 venues, it's LocalBusiness.
Multilingual Schema With inLanguage
Deluxe Astrology runs in 30 languages. Every schema block includes inLanguage, and we use hreflang-aware URLs:
function buildMultilingualArticleSchema(
page: HoroscopePage,
allLanguages: string[]
) {
return {
'@type': 'Article',
headline: page.title,
description: page.meta_description,
inLanguage: page.lang,
datePublished: page.published_at,
dateModified: page.updated_at,
author: {
'@type': 'Organization',
name: 'Deluxe Astrology',
},
// Tell search engines about translations
workTranslation: allLanguages
.filter((lang) => lang !== page.lang)
.map((lang) => ({
'@type': 'Article',
inLanguage: lang,
url: `https://deluxeastrology.com/${lang}/horoscope/${page.sign}/${page.period}`,
})),
};
}
The inLanguage property uses BCP 47 language tags (en, fr, de, ja, etc.). This is critical for multilingual sites — without it, Google may misidentify the language of your structured data and serve it to the wrong audience.
Validation and Monitoring Tools
Shipping schema without validation is like deploying without tests. Here's our toolkit:
| Tool | Purpose | Cost | When to Use |
|---|---|---|---|
| Google Rich Results Test | Validates eligibility for rich results | Free | Before deploy, spot checks |
| Schema Markup Validator | Full schema.org spec validation | Free | Catches property errors Google's tool ignores |
| Screaming Frog Custom Extraction | Crawls site, extracts JSON-LD from every page | £199/year (paid license) | Bulk validation across 91K+ pages |
| Google Search Console | Monitors indexed schema, surfaces errors | Free | Ongoing production monitoring |
| Rich Results Status reports | Shows which pages have valid/invalid schema | Free | Weekly review |
Screaming Frog Custom Extraction for Schema at Scale
This is how you validate 91,000 pages without manually checking each one. In Screaming Frog:
- Go to Configuration → Custom → Extraction
- Add a custom extraction with CSSPath:
script[type="application/ld+json"] - Set extraction to "Extract Inner HTML"
- Crawl your site
- Export and parse the JSON to validate programmatically
We pipe the export through a Node script that checks for required properties per schema type and flags any pages with missing or malformed data. It catches issues like empty headline fields or dates in the wrong format before Google does.
Common Mistakes That Will Tank Your Rich Results
We've made most of these. Learn from our pain.
1. Schema content doesn't match visible content. If your Article schema says the headline is "Best Restaurants in London" but the actual <h1> says something different, Google will ignore or penalize the schema. The data must reflect what's on the page.
2. Using schema types for pages that don't qualify. Don't slap FAQPage schema on a page that doesn't actually display FAQ content. Google's manual actions team catches this, and the penalty removes ALL your rich results, not just the offending pages.
3. Missing required properties. Article needs headline and image. LocalBusiness needs name and address. Check the Google structured data docs for requirements per type.
4. Rendering schema in client components. In Next.js App Router, if you render JSON-LD inside a 'use client' component, it won't be in the initial HTML. Googlebot will usually execute JS, but other crawlers (including some LLM crawlers) won't. Always use server components.
5. Duplicate schema across nested layouts. If your root layout.tsx and a nested layout.tsx both render Organization schema, you'll have duplicates. Deduplicate by only placing each schema type at the most specific appropriate level.
6. Not escaping special characters in JSON. If your article title or FAQ answer contains unescaped quotes or angle brackets, the JSON breaks silently. JSON.stringify() handles most cases, but watch for content pulled from user-generated data.
7. Using deprecated or unsupported schema types. See the next section.
Google 2025-2026 Deprecations and Changes
Google has been tightening which schema types trigger rich results:
- FAQPage rich results removed for most sites (August 2023, still in effect): Only government and health authority sites get FAQ rich results in SERPs now. BUT — and this is crucial — Google still reads and processes FAQPage schema. It just doesn't show the expandable FAQ in search results for most sites. For LLM citation purposes, the schema is still gold.
- HowTo rich results removed from mobile (September 2023, still in effect): Desktop still shows them occasionally, but Google has deprioritized HowTo rich results significantly.
- Sitelinks Searchbox deprecation (November 2024): The
WebSiteschema'sSearchActionno longer guarantees a sitelinks searchbox, but Google may still use it internally. - AI Overviews prioritize structured data (2025-2026): Google's AI Overviews increasingly pull from pages with structured data. The schema doesn't guarantee inclusion, but pages without it are measurably less likely to be cited.
Our recommendation: keep implementing FAQPage, HowTo, and all schema types even if Google's SERP features have been reduced. The data is consumed by multiple systems now — Google's AI, ChatGPT's browse mode, Perplexity, Bing Copilot. The value extends far beyond traditional rich results.
If you're building a headless site and want help implementing this at scale, check out our headless CMS development capabilities or get in touch.
FAQ
Does FAQPage schema still work for SEO in 2026? Yes, but differently than before. Google removed FAQ rich results for most sites in 2023, so you won't see expandable FAQ snippets in search results. However, Google still processes the schema internally, and LLM-powered search tools like ChatGPT, Perplexity, and Google's AI Overviews actively extract Q&A pairs from FAQPage markup. We've measured a 4x increase in LLM citations on pages with FAQPage schema versus those without.
How do you add JSON-LD schema markup in Next.js App Router?
Create a server component that renders a <script type="application/ld+json"> tag using dangerouslySetInnerHTML with JSON.stringify() on your schema object. Place it inside your page's server component — never in a client component. For site-wide schema like Organization, put it in layout.tsx. For page-specific schema like Article or FAQPage, generate it from your data in each page.tsx.
Can you have multiple JSON-LD script tags on one page?
Absolutely. Google explicitly supports multiple JSON-LD blocks on a single page. We routinely render separate blocks for Article, FAQPage, BreadcrumbList, and Organization on the same page. Each gets its own <script type="application/ld+json"> tag with its own @context.
How do you generate schema markup for thousands of programmatic pages?
Build schema objects from your database rows in server components. We use generateStaticParams in Next.js to create paths for all pages, then each page's server component fetches its data from Supabase and constructs the JSON-LD dynamically. The schema is baked into static HTML at build time. For 91,000 pages, this runs during the build process with ISR handling updates.
What's the difference between Article and BlogPosting schema? BlogPosting is a subtype of Article. Use BlogPosting for blog posts with a clear publication date and author. Use Article for more general editorial content like news articles or guides. In practice, Google treats them almost identically. We use Article for most content and BlogPosting only for explicitly blog-formatted posts.
Does schema markup help with Google AI Overviews? Yes. Pages with structured data are measurably more likely to be cited in AI Overviews. The schema helps Google's AI understand entity relationships, content type, and data accuracy. FAQPage schema is particularly effective because it provides pre-structured Q&A pairs that the AI can extract directly. It's not a guarantee of inclusion, but it significantly improves your odds.
What tools should I use to validate schema markup at scale? For individual pages, use Google's Rich Results Test and the Schema Markup Validator at validator.schema.org. For bulk validation across thousands of pages, use Screaming Frog's custom extraction feature to crawl your site and extract all JSON-LD, then run the output through a validation script. Monitor ongoing issues in Google Search Console's structured data reports.
Should I implement schema types that Google no longer shows rich results for? Yes. Google's SERP features are just one consumer of your structured data. ChatGPT, Perplexity, Bing Copilot, and other AI systems all read schema markup. Even if Google stopped showing HowTo rich results on mobile, the schema still helps LLMs understand your content. Think of structured data as a universal machine-readable layer, not just a Google feature.