
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results
Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or when it was last edited? Is "Maria" the author or someone mentioned in the text? Structured data removes the guessing. You describe the page's content in a standard vocabulary, and search engines can use it for rich results: star ratings, prices and stock status, breadcrumb trails instead of raw URLs, article dates, and more. AI assistants and other tools that read the web increasingly use the same data.
JSON-LD is the format Google recommends for structured data, and adding it to a Next.js App Router page takes only a few lines. Doing it well takes a bit more care: escaping the output so it can't be used for script injection, keeping it in sync with visible content, typing it so mistakes are caught early, and avoiding duplicates between layouts and pages. This post covers all of that, with ready-to-use examples for the most common page types.
What JSON-LD Looks Like
JSON-LD is a JSON object, embedded in a script tag with type application/ld+json, using the vocabulary from schema.org. A minimal article looks like this:
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Adding JSON-LD Structured Data to Next.js Pages",
"datePublished": "2026-09-30T15:45:00Z",
"author": {
"@type": "Person",
"name": "Maria"
}
}
@context says which vocabulary you're using, @type says what kind of thing this is, and the remaining properties describe it. Browsers ignore the script entirely. It's there for machines.
Rendering JSON-LD in the App Router
The Metadata API doesn't have a JSON-LD field. The recommended approach is to render a regular script element directly in your page.tsx or layout.tsx:
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
datePublished: post.publishedAt,
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
<h1>{post.title}</h1>
{/* ... */}
</article>
);
}
Three details here are deliberate.
A native script, not next/script. The Script component from next/script is for loading and executing JavaScript with a loading strategy. JSON-LD isn't executable, and you want it in the server-rendered HTML exactly as written, so a plain script element is correct.
It's rendered in a Server Component. The JSON-LD ends up in the initial HTML, where crawlers read it, and none of it ships as client JavaScript.
The < replacement. This is the important one. JSON.stringify doesn't escape HTML. If any value contains the string </script> (say, a post title or a user-submitted review), it closes your script tag early, and whatever follows is parsed as HTML. That's a script injection vulnerability. Replacing every < with its Unicode escape < keeps the JSON valid (JSON parsers decode it back to <) while making it impossible to close the tag. Do this every time, even for data you think is safe.
A Reusable, Typed Component
Repeating that script block and escape on every page invites mistakes. Wrap it in a small component, and add types with the schema-dts package so typos in property names are caught by TypeScript:
npm install -D schema-dts
// components/json-ld.tsx
import type { Graph, Thing, WithContext } from "schema-dts";
type Props = {
data: WithContext<Thing> | Graph;
};
export function JsonLd({ data }: Props) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}
schema-dts provides TypeScript types for the full schema.org vocabulary. WithContext<BlogPosting> requires @context and @type and only allows properties that exist on BlogPosting and its parent types. Graph is for multiple linked entities, which we'll get to shortly. It's a dev dependency because it only contains types.
Now a post page looks like this:
// app/blog/[slug]/page.tsx
import type { BlogPosting, WithContext } from "schema-dts";
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/json-ld";
import { getPost } from "@/lib/posts";
import { BASE_URL } from "@/lib/site";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
const url = `${BASE_URL}/blog/${post.slug}`;
const jsonLd: WithContext<BlogPosting> = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
image: [`${BASE_URL}${post.coverImage}`],
datePublished: post.publishedAt,
dateModified: post.updatedAt ?? post.publishedAt,
author: {
"@type": "Person",
name: post.author.name,
url: `${BASE_URL}/authors/${post.author.slug}`,
},
publisher: { "@id": `${BASE_URL}/#organization` },
mainEntityOfPage: url,
};
return (
<article>
<JsonLd data={jsonLd} />
<h1>{post.title}</h1>
{/* ... */}
</article>
);
}
A few notes on the properties:
headlineshould match the visible title. Keep it reasonably short; very long headlines may be truncated in results.imageshould be absolute URLs of images that actually appear on or represent the page. Google recommends providing high-resolution images, ideally in multiple aspect ratios if you have them.- Dates should be ISO 8601 with a timezone, like
2026-09-30T15:45:00Z. A date without time is allowed, but including the timezone avoids your post appearing a day off. authorwith aurlpointing at an author page helps search engines connect articles by the same person.publisherreferences an organization defined elsewhere by its@id. More on that next.
Site-Wide Data: Organization and WebSite
Some entities describe the whole site rather than one page: your organization (name, logo, social profiles) and the website itself. Put these in the root layout so they're on every page, and give each one an @id so pages can reference them instead of repeating them.
// app/layout.tsx
import type { ReactNode } from "react";
import type { Graph } from "schema-dts";
import { JsonLd } from "@/components/json-ld";
import { BASE_URL } from "@/lib/site";
const siteJsonLd: Graph = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": `${BASE_URL}/#organization`,
name: "TideWave",
url: BASE_URL,
logo: `${BASE_URL}/images/logo.png`,
sameAs: [
"https://github.com/tidewave",
"https://www.linkedin.com/company/tidewave",
],
},
{
"@type": "WebSite",
"@id": `${BASE_URL}/#website`,
name: "TideWave",
url: BASE_URL,
publisher: { "@id": `${BASE_URL}/#organization` },
},
],
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<JsonLd data={siteJsonLd} />
{children}
</body>
</html>
);
}
@graph holds several entities in one block. Each has an @id, a URL-shaped identifier that doesn't need to resolve to a real page; it just has to be unique and stable. The BlogPosting above uses publisher: { "@id": ".../#organization" } to point at this organization. Search engines combine all JSON-LD blocks on a page, so references work across separate script tags.
The Organization data with a logo and sameAs links helps search engines associate your brand with its logo and official profiles.
Breadcrumbs
Breadcrumb structured data can replace the raw URL under your result with a readable trail like "TideWave > Blog > Next.js". It's one of the most broadly useful types, and easy to generate from the route.
// lib/breadcrumbs.ts
import type { BreadcrumbList, WithContext } from "schema-dts";
import { BASE_URL } from "@/lib/site";
export function breadcrumbJsonLd(
items: { name: string; path: string }[],
): WithContext<BreadcrumbList> {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: `${BASE_URL}${item.path}`,
})),
};
}
// in app/blog/[slug]/page.tsx, alongside the BlogPosting
<JsonLd
data={breadcrumbJsonLd([
{ name: "Home", path: "/" },
{ name: "Blog", path: "/blog" },
{ name: post.title, path: `/blog/${post.slug}` },
])}
/>
Positions start at 1. The breadcrumb should reflect a real navigational hierarchy on your site, ideally matching a visible breadcrumb component on the page.
Products, Offers, and Reviews
For e-commerce pages, Product structured data can show price, availability, and ratings directly in results, which tends to have the biggest impact on click-through of any type.
// app/products/[slug]/page.tsx
import type { Product, WithContext } from "schema-dts";
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/json-ld";
import { getProduct } from "@/lib/products";
import { BASE_URL } from "@/lib/site";
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
const jsonLd: WithContext<Product> = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.images.map((src) => `${BASE_URL}${src}`),
sku: product.sku,
brand: { "@type": "Brand", name: product.brand },
offers: {
"@type": "Offer",
url: `${BASE_URL}/products/${product.slug}`,
price: product.price.toFixed(2),
priceCurrency: "USD",
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
...(product.reviewCount > 0 && {
aggregateRating: {
"@type": "AggregateRating",
ratingValue: product.rating,
reviewCount: product.reviewCount,
},
}),
};
return (
<main>
<JsonLd data={jsonLd} />
<h1>{product.name}</h1>
{/* price, stock status, reviews... */}
</main>
);
}
Points that commonly cause validation warnings or ineligibility:
priceandpriceCurrencymust match what the page shows. Use a string or number without currency symbols.availabilityuses full schema.org URLs likehttps://schema.org/InStock.aggregateRatingmust reflect real reviews visible on the page. Including it only when there are reviews (the conditional spread above) avoids publishing a rating of zero from zero reviews.
Choosing Types Worth Adding
Schema.org has hundreds of types, but only some lead to rich results in Google. Focus on types that match your content and are actually used:
| Page | Useful types |
|---|---|
| Every page | Organization, WebSite (root layout) |
| Blog post or news article | BlogPosting or Article, BreadcrumbList |
| Product page | Product with Offer, AggregateRating, Review |
| Event page | Event with location, startDate, offers |
| Job listing | JobPosting |
| Recipe | Recipe |
| Software or app page | SoftwareApplication |
| Local business | LocalBusiness (or a subtype) with address and hours |
A couple of types are worth a caveat. Google narrowed FAQPage rich results to a small set of authoritative government and health sites in 2023, and stopped showing HowTo rich results. The markup is still valid and other consumers may read it, but don't expect visible changes in Google results from adding it to a typical site.
Check Google's search gallery documentation for the current list of supported features and their required properties. Requirements change over time, and the documentation is the source of truth.
Keeping Structured Data Honest
Search engines treat structured data as a claim about the page, and they check it against what users see. A few rules keep you on the right side of their guidelines:
- Describe what's visible. Don't mark up reviews, prices, or FAQs that aren't on the page.
- Generate it from the same data as the page. Building JSON-LD from the same
postorproductobject that renders the HTML guarantees they match. Hand-maintained JSON-LD drifts. - Don't mark up hidden or irrelevant content. A rating for your company on every product page, for example, isn't a rating for that product.
Violations can lead to a manual action that removes rich results for your site, so this isn't just pedantry.
Avoiding Duplicates Between Layouts and Pages
Because layouts wrap pages, it's easy to end up with two blocks describing the same thing, for example a WebPage in a layout and an Article in the page with conflicting names. Some guidelines:
- Put site-wide entities (
Organization,WebSite) in the root layout, once. - Put page-specific entities (
BlogPosting,Product,BreadcrumbList) in thepage.tsxthat has the data. - Link them by
@idrather than repeating the organization inside every article.
Multiple script tags of type application/ld+json on one page are perfectly fine. Duplicated or contradictory entities are what cause confusion.
Testing and Validation
Before shipping, check the output in three places:
- View source on a production build and search for
application/ld+json. Confirm the JSON is in the server-rendered HTML and the values look right. - Rich Results Test at https://search.google.com/test/rich-results shows which rich result types Google detects on a URL or code snippet, plus errors and warnings for missing properties.
- Schema Markup Validator at https://validator.schema.org/ validates against the full schema.org vocabulary, which is useful for types Google doesn't use for rich results.
After deployment, Google Search Console shows enhancement reports for detected types (breadcrumbs, products, and others), with counts of valid items and errors across your whole site. That's where you'll notice if a template change breaks structured data on hundreds of pages at once.
For local testing of a preview URL that isn't publicly reachable, paste the rendered HTML into the Rich Results Test's code tab.
FAQ
Can I put JSON-LD in the head?
It works in either the head or the body. Rendering it in your page or layout component, as shown here, places it in the body, which search engines read without any issue.
Will this work with Client Components? It would render, but there's no benefit. Keep JSON-LD in Server Components so it's in the initial HTML and adds nothing to your JavaScript bundle.
Does structured data improve rankings directly? It's not a direct ranking factor. It makes your results eligible for richer presentation, which can improve click-through. For the broader picture, see the SEO benefits of Next.js.
Conclusion
JSON-LD in Next.js is a script element with type application/ld+json, rendered from a Server Component. Wrap it in a small component that always escapes < as <, type the data with schema-dts, and build it from the same objects that render the page. Put Organization and WebSite in the root layout with stable @id values, add BlogPosting, Product, and BreadcrumbList to the pages they describe, and validate with the Rich Results Test before you ship. Combined with good metadata from the Metadata API, that gives search engines everything they need to present your pages well.


