How to Add Schema Markup to a Next.js App Router Site
App Router metadata API doesn't cover JSON-LD. Here's the production pattern — type-safe schema, generated per page, indexed by Google.
Next.js 13+ introduced the metadata export as the canonical way to set <title>, <meta> descriptions, Open Graph tags, and canonical URLs. It handles a lot. What it doesn't handle is structured data — there's no metadata.schema or equivalent. JSON-LD has to be added separately, and the App Router's server component model means the approach is different from the Pages Router pattern you might have used before.
The production pattern is straightforward once you see it: a small server component that emits a <script type="application/ld+json"> tag using dangerouslySetInnerHTML, called from your page component alongside the regular content. This guide builds that pattern from scratch.
Why metadata doesn't cover JSON-LD
The Next.js metadata API maps 1:1 to HTML <meta> and <link> tags — things like <meta name="description">, <meta property="og:title">, and <link rel="canonical">. Structured data is a <script> tag, not a meta tag, so it falls outside the API's scope by design.
The Next.js team has discussed adding a jsonLd key to the metadata API (GitHub discussion #49130), but as of May 2026, there's no built-in solution. The community pattern using dangerouslySetInnerHTML is what you'll find in production Next.js apps and in the Next.js documentation examples for structured data.
The base component
Create a reusable component that accepts any schema object and renders it as a <script> tag. Keep it simple:
// components/JsonLd.tsx
type JsonLdProps = {
data: Record<string, unknown>;
};
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
This is a Server Component by default (no "use client" directive needed). It renders to a static string on the server, which means Google's crawler sees the JSON-LD in the raw HTML response — no JavaScript execution required.
A typed schema builder for BlogPosting
Rather than constructing raw objects inline, create a typed helper that builds the schema from your page's data shape. This makes it easier to reuse across pages and ensures you don't forget required fields.
// lib/schema.ts
export type ArticleSchemaInput = {
title: string;
description: string;
publishedAt: string; // ISO 8601: "2026-05-23"
updatedAt: string;
imageUrl: string;
authorName: string;
canonicalUrl: string;
publisherName: string;
publisherLogoUrl: string;
};
export function buildArticleSchema(input: ArticleSchemaInput) {
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: input.title,
description: input.description,
image: input.imageUrl,
datePublished: input.publishedAt,
dateModified: input.updatedAt,
author: {
"@type": "Person",
name: input.authorName,
},
publisher: {
"@type": "Organization",
name: input.publisherName,
logo: {
"@type": "ImageObject",
url: input.publisherLogoUrl,
},
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": input.canonicalUrl,
},
};
}
Using it in app/blog/[slug]/page.tsx
Here's how the pieces fit together in a real App Router blog post page:
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/JsonLd";
import { buildArticleSchema } from "@/lib/schema";
import { getPost } from "@/lib/posts"; // your data fetching function
type Props = {
params: { slug: string };
};
export async function generateMetadata({ params }: Props) {
const post = await getPost(params.slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.featuredImageUrl],
},
alternates: {
canonical: `https://yoursite.com/blog/${params.slug}`,
},
};
}
export default async function BlogPostPage({ params }: Props) {
const post = await getPost(params.slug);
if (!post) notFound();
const schema = buildArticleSchema({
title: post.title,
description: post.excerpt,
publishedAt: post.publishedAt,
updatedAt: post.updatedAt ?? post.publishedAt,
imageUrl: post.featuredImageUrl,
authorName: post.author.name,
canonicalUrl: `https://yoursite.com/blog/${params.slug}`,
publisherName: "Your Site Name",
publisherLogoUrl: "https://yoursite.com/logo.png",
});
return (
<>
<JsonLd data={schema} />
<article>
<h1>{post.title}</h1>
{/* rest of your article content */}
</article>
</>
);
}
The <JsonLd /> component renders before the <article> in the HTML, but placement within the body is fine — Google reads structured data wherever it appears in the document.
Adding FAQ schema to the same page
If your blog posts include an FAQ section, you can render a second <JsonLd /> block on the same page. Multiple structured data blocks on one page are valid and supported.
import { buildFaqSchema } from "@/lib/schema";
// In your page component:
const faqSchema = buildFaqSchema(post.faqs); // faqs: { question: string; answer: string }[]
return (
<>
<JsonLd data={schema} />
{post.faqs.length > 0 && <JsonLd data={faqSchema} />}
<article>...</article>
</>
);
Add a buildFaqSchema function to lib/schema.ts:
export function buildFaqSchema(faqs: { question: string; answer: string }[]) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer,
},
})),
};
}
Validation
After deploying, paste your page URL into the Google Rich Results Test. It should detect the BlogPosting type and show you eligible rich results. If it shows warnings for missing fields, cross-reference against RankCrab's schema generator to confirm which fields are required for your schema type.
What about the root layout?
You can also add organization-wide schema (like Organization or WebSite) to your root app/layout.tsx. This gives you one place to maintain site-level schema instead of repeating it on every page:
// app/layout.tsx
import { JsonLd } from "@/components/JsonLd";
const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
name: "Your Company",
url: "https://yoursite.com",
logo: "https://yoursite.com/logo.png",
sameAs: [
"https://twitter.com/yourhandle",
"https://linkedin.com/company/yourcompany",
],
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<JsonLd data={organizationSchema} />
{children}
</body>
</html>
);
}
Page-level schema from individual page components stacks with the root layout schema — both blocks will appear in the rendered HTML.
Related guides
For a complete overview of schema types and when to use each one, see the schema markup guide. If you're getting validation errors after deploying, the validation errors guide covers the most common Rich Results Test failures.
The same JSON-LD structure works across platforms — see the Webflow CMS collection approach and the Shopify article.liquid pattern if you're working across multiple platforms.