
Next.js Implementing JSON-LD
If you've ever wondered how Google shows a recipe's star rating and cook time directly in search results, or how a product listing gets a price and stock status right there on the results page, the answer is almost always JSON-LD. It's a small, easy-to-overlook piece of markup that has an outsized effect on how machines — search engines, AI crawlers, browser assistants — understand a page that a human would otherwise have to read and interpret themselves.
Next.js doesn't ship a dedicated JSON-LD component the way it does for images, fonts, or scripts. That's a deliberate choice, and once you understand why, adding JSON-LD to an App Router project becomes a fairly small, mechanical task. This article walks through what JSON-LD actually is, how to render it safely in Next.js, how to type it, how to keep it in sync with your real page content, and the mistakes that quietly break it in production.
What JSON-LD Actually Is
JSON-LD stands for JSON for Linking Data. It's a way of embedding structured data — data about your content, not the content itself — directly inside an HTML page, using a vocabulary called schema.org that search engines, AI systems, and other tools have agreed to recognize.
Where your visible page might say "Wireless Noise-Cancelling Headphones — $249.99 — In Stock," a JSON-LD block says the same thing in a machine-readable shape:
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Wireless Noise-Cancelling Headphones",
"offers": {
"@type": "Offer",
"price": "249.99",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}
The @context field tells a parser which vocabulary you're using (almost always https://schema.org), and @type tells it which kind of thing you're describing — a Product, an Article, an Organization, a Recipe, an Event, and dozens of other types. Everything else is just key-value data shaped to that type's expected fields.
This is different from a meta description or Open Graph tags, which describe the page as a shareable unit (title, summary, preview image). JSON-LD describes the content itself as a semantic entity. A page can have Open Graph tags for how it looks when shared on social media, and JSON-LD for what the page fundamentally represents to a machine parsing it for a knowledge graph or a rich result.
Why Next.js Doesn't Give You a Component for This
Every other content-adjacent feature in Next.js — images, fonts, third-party scripts — gets a dedicated abstraction (next/image, next/font, next/script) because those things have real, non-trivial optimization work to do: resizing, subsetting, deferred loading, layout shift prevention. JSON-LD has none of that. It's inert data sitting in a <script type="application/ld+json"> tag. The browser never executes it, parses it as JavaScript, or does anything with it at runtime — it just sits there for crawlers to read.
Because there's no optimization work to abstract away, the Next.js team's guidance is refreshingly simple: render a plain <script> tag yourself, directly in layout.tsx or page.tsx. No package, no special import, no configuration. This is actually a good design decision to internalize — not every "add X to my page" problem needs a framework feature. Sometimes the framework's job is to get out of your way.
Rendering Your First JSON-LD Block
Here's the canonical shape, adapted for a product detail page:
// app/products/[id]/page.tsx
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.image,
description: product.description,
};
return (
<section>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
<h1>{product.name}</h1>
<p>{product.description}</p>
</section>
);
}
A few things worth noticing here that aren't obvious on a first read:
It's a real <script> tag, not next/script. This trips people up because next/script is the tool you reach for whenever a script tag appears in a Next.js codebase. But next/script exists to manage executable JavaScript — controlling when a third-party analytics or chat-widget script loads relative to page interactivity. JSON-LD isn't executable; it's just typed data wrapped in a <script type="application/ld+json"> tag so the browser knows not to try to run it as JavaScript. Using next/script here would add loading-strategy machinery around something that has no execution to strategize about, and in some configurations it can outright fail to render non-JS script types correctly. Use a plain <script>.
It works inside a Server Component with no extra ceremony. Because it's just markup being rendered, you can build the jsonLd object from data you already fetched in the same async Server Component, no client-side JavaScript required, no hydration cost.
dangerouslySetInnerHTML is required, and that name is doing you a favor. React doesn't let you put arbitrary strings into a script tag's content without it, precisely because of what we cover next.
The XSS Problem Hiding in Plain Sight
This is the part of JSON-LD that's easy to skip past, and the part that will actually bite you if you do.
JSON.stringify is not an HTML-safe serializer. It's a JavaScript-object-to-string serializer. If any of the data going into your jsonLd object comes from user input — a product name someone typed into a CMS, a review title, a user-generated event description — and that string happens to contain something like </script><script>alert(1)</script>, a naive JSON.stringify(jsonLd) will happily produce a string containing that raw HTML. Since you're injecting the result directly into the DOM via dangerouslySetInnerHTML, the browser will parse it as HTML, not as inert JSON text, and your "structured data" script tag becomes an XSS payload.
The fix Next.js recommends is small but non-negotiable: replace every < character with its Unicode escape, <, before it goes into the dangerouslySetInnerHTML string:
JSON.stringify(jsonLd).replace(/</g, "\\u003c");
This works because < is functionally identical to < once parsed back as JSON or read by a crawler, but it's inert as far as the browser's HTML parser is concerned — a browser scanning for </script> to close the tag won't find it, because there's no literal < character left in the string. It's a narrow, surgical fix for a narrow, surgical problem, and it's easy to accidentally omit when you're pattern-matching from an existing JSON-LD example that didn't need it because its data was hardcoded.
If you want something more defensive than a single regex replace — for instance if your data could plausibly contain other HTML-significant sequences — reach for a purpose-built serializer like serialize-javascript, which handles this class of problem more thoroughly (it also serializes values JSON.stringify can't, like undefined, functions, and regexes, though you generally don't want those in JSON-LD anyway). For most product/article/blog use cases, the .replace(/</g, '\\u003c') pattern is sufficient and is what you'll see in the overwhelming majority of production Next.js codebases.
The mistake this catches: teams that hardcode their first JSON-LD block from marketing copy, ship it, then later wire the same component up to real CMS data without re-checking the sanitization step. The bug is invisible until someone puts a stray < in a product title or blog post body, and then you have unsanitized user content executing in every visitor's browser. Treat the escape as part of the pattern, not an optional hardening step.
Typing JSON-LD with TypeScript
Schema.org has hundreds of types, each with its own set of expected and optional fields, and writing them as plain object literals means TypeScript can't catch a typo'd field name or a Product object that's missing a field a Recipe actually needed. The community package schema-dts solves this by shipping TypeScript types generated directly from the schema.org vocabulary:
import type { Product, WithContext } from "schema-dts";
const jsonLd: WithContext<Product> = {
"@context": "https://schema.org",
"@type": "Product",
name: "Next.js Sticker",
image: "https://nextjs.org/imgs/sticker.png",
description: "Dynamic at the speed of static.",
};
WithContext<T> is the piece worth understanding — it's a wrapper type that adds the required @context field on top of whatever schema.org type you're describing, since @context isn't part of the Product type itself but is required by the JSON-LD spec at the top level of the document.
Once you're typing your JSON-LD objects, TypeScript will flag it immediately if you try to describe an Article using Recipe-only fields, or if you forget a field the type marks as required. For a codebase generating JSON-LD across many different content types (products, articles, FAQs, events), this pays for itself the first time someone copies a JSON-LD block for a new page and forgets to swap out the type-specific fields.
Building JSON-LD from Real Data, Not Just Product Pages
The example in the docs is deliberately minimal, but real JSON-LD for an e-commerce product usually carries more fields, because richer data unlocks richer search result treatments (price, availability, and rating stars showing up directly in search):
// app/products/[id]/page.tsx
import type { Product, WithContext } from "schema-dts";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
const jsonLd: WithContext<Product> = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.images,
description: product.description,
sku: product.sku,
brand: {
"@type": "Brand",
name: product.brandName,
},
offers: {
"@type": "Offer",
url: `https://example.com/products/${product.id}`,
priceCurrency: "USD",
price: product.price,
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
aggregateRating:
product.reviewCount > 0
? {
"@type": "AggregateRating",
ratingValue: product.averageRating,
reviewCount: product.reviewCount,
}
: undefined,
};
return (
<section>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
{/* visible product UI */}
</section>
);
}
Notice the conditional aggregateRating — a JSON-LD field with undefined as its value gets dropped entirely by JSON.stringify (it simply omits keys whose value is undefined), which is the correct behavior here: you don't want to claim a product has zero reviews with a fabricated rating object, you want the field to not exist at all when there's nothing to report.
Common Schema Types You'll Actually Reach For
Beyond Product, a handful of other schema.org types cover almost every content-heavy Next.js site. Here's a fast reference for the ones you'll actually use, since discovering the right field names by trial and error against the Rich Results Test is a slow way to learn this.
Article (blog posts, news content):
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
name: post.author.name,
},
};
BreadcrumbList (helps search engines render the little Home > Category > Product trail in results):
const jsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://example.com",
},
{
"@type": "ListItem",
position: 2,
name: "Blog",
item: "https://example.com/blog",
},
{ "@type": "ListItem", position: 3, name: post.title, item: post.url },
],
};
FAQPage (renders expandable Q&A directly in search results — genuinely one of the highest-leverage schema types for organic click-through):
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer,
},
})),
};
Organization (usually rendered once, in the root layout, describing the business itself rather than any one page):
// app/layout.tsx
const jsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "Example Co.",
url: "https://example.com",
logo: "https://example.com/logo.png",
sameAs: [
"https://twitter.com/examplecom",
"https://www.linkedin.com/company/examplecom",
],
};
Recipe (unlocks the cook-time/rating card treatment in results, which is one of the most visually distinct rich results available):
const jsonLd = {
"@context": "https://schema.org",
"@type": "Recipe",
name: recipe.title,
image: recipe.image,
author: { "@type": "Person", name: recipe.author },
prepTime: recipe.prepTimeIso, // e.g. "PT15M"
cookTime: recipe.cookTimeIso, // e.g. "PT30M"
recipeYield: recipe.servings,
recipeIngredient: recipe.ingredients,
recipeInstructions: recipe.steps.map((step) => ({
"@type": "HowToStep",
text: step.text,
})),
};
Note the prepTime/cookTime fields expect ISO 8601 duration strings (PT15M for fifteen minutes), not plain numbers or free text — this is one of the more common validation failures for Recipe schema, since it's the one field format on this page that doesn't look like anything else in JSON-LD.
Event (concerts, webinars, conferences — surfaces start/end times and location directly in results):
const jsonLd = {
"@context": "https://schema.org",
"@type": "Event",
name: event.title,
startDate: event.startsAt, // ISO 8601 datetime
endDate: event.endsAt,
location: {
"@type": "Place",
name: event.venueName,
address: event.venueAddress,
},
offers: {
"@type": "Offer",
url: event.ticketUrl,
price: event.ticketPrice,
priceCurrency: "USD",
availability: "https://schema.org/InStock",
},
};
A quick rule of thumb for where to put each of these: page-specific types (Product, Article, Recipe, Event) belong in page.tsx for that route, close to the data that fills them in. Site-wide types (Organization, WebSite) belong once in the root layout.tsx, not repeated on every page — search engines associate the Organization entity with the site as a whole, and duplicating it on every route adds bytes without adding signal.
Combining Multiple Schema Types on One Page
Some pages genuinely describe more than one entity at once — a blog post page that's simultaneously an Article and part of a BreadcrumbList, or a product page that's a Product with an embedded AggregateRating and a separate FAQPage block for a Q&A section further down. You have two options, and the difference matters.
The simplest option is multiple <script> tags, one per type:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd).replace(/</g, '\\u003c') }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, '\\u003c') }}
/>
This works everywhere and is the easiest to reason about — each block is independently valid JSON-LD, and you can add or remove one without touching the others.
The more "correct" approach per the JSON-LD spec is a single @graph array, which explicitly tells a parser that multiple entities on the page are related:
const jsonLd = {
"@context": "https://schema.org",
"@graph": [articleJsonLd, breadcrumbJsonLd],
};
In practice, both are widely supported by Google's tooling, and the multiple-<script>-tags approach is easier to compose from independent pieces of a page (an Article block built in page.tsx, a BreadcrumbList block built in a shared layout component). Reach for @graph only when you specifically need to express a relationship between entities that separate top-level blocks can't capture — for most Next.js apps, that's rare enough that the simpler multi-script approach is the better default.
Checking Your Work Locally Before It Ships
You don't need to deploy to see whether your JSON-LD is rendering correctly. Run next dev, load the page in a browser, and use "View Page Source" (not just DevTools' Elements panel, which shows the live DOM after any client-side mutations) to confirm the <script type="application/ld+json"> tag is present with the data you expect, already escaped. This matters specifically for JSON-LD rendered inside a Server Component: because it comes from the server-rendered HTML rather than a client-side effect, it should already be visible in the initial source, with no need to wait for hydration. If it's missing from "View Page Source" but present in DevTools' live DOM inspector, that's a sign something is rendering it client-side when it should be server-rendered — worth investigating before you assume crawlers (which typically only see server-rendered HTML, not post-hydration DOM mutations) will pick it up correctly.
Combining JSON-LD with generateMetadata
JSON-LD and the Next.js Metadata API solve adjacent but different problems, and a well-built page usually uses both together — one for how the page looks when shared, one for what the page is to a machine:
// app/products/[id]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
return {
title: product.name,
description: product.description,
openGraph: {
images: [product.image],
},
};
}
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.image,
description: product.description,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
{/* page content */}
</>
);
}
Both functions fetch the same product — in practice, you'd wrap getProduct in React's cache() (or rely on Next.js's Data Cache, depending on your caching setup) so that calling it once in generateMetadata and again in page.tsx doesn't issue the request twice. That's a general App Router data-fetching concern, not something specific to JSON-LD, but it's the first thing people hit once they start populating both metadata and structured data from the same source.
Validating What You've Built
Don't ship JSON-LD you haven't actually tested — a single typo'd field name silently produces a schema that validates as some type but not the one you intended, and you won't find out until a rich result you expected never shows up. Two tools cover almost every case:
Rich Results Test — Google's own tool. Paste a URL or raw HTML and it tells you specifically which rich result types (if any) your structured data qualifies for, and flags missing recommended fields.
Schema Markup Validator — a vocabulary-level validator, not tied to Google specifically, useful for checking that your JSON-LD is valid schema.org regardless of which search engine or AI system ends up consuming it.
Run your JSON-LD through one of these after every non-trivial schema change, not just once at launch. Field requirements and rich-result eligibility do shift over time (Google in particular has quietly deprecated rich-result support for some schema types), and code that was correct when written can go silently stale.
Mistakes That Quietly Undermine Your JSON-LD
Structured data that doesn't match the visible page. Search engines actively check whether your JSON-LD claims line up with what a user would actually see on the page — a Product schema claiming a price that isn't shown anywhere in the visible HTML is treated as a signal of manipulation, not a bonus. Generate JSON-LD from the same data source that renders your visible content, never from a separate, hand-maintained object that can drift out of sync.
Duplicating site-wide schema on every page. If your Organization or WebSite schema lives in a shared layout component that's also imported by nested layouts, you can end up emitting the same block multiple times per page. This doesn't usually break parsing, but it bloats every response and can confuse tools that expect exactly one instance of a singleton type per page.
Forgetting the XSS escape once data becomes dynamic. Covered above, but worth repeating because it's the single most common regression: JSON-LD prototyped against hardcoded strings works fine without the < escape, and the omission goes unnoticed until real (and potentially unsanitized) content flows through the same code path.
Reaching for next/script out of habit. It's built for scripts with actual execution semantics and loading strategies. JSON-LD has neither. A plain <script type="application/ld+json"> is correct, simpler, and avoids any surprises from a loading strategy interacting badly with a non-JS script type.
Treating JSON-LD as a ranking hack. It isn't one. Google has been explicit for years that structured data doesn't directly improve ranking position — what it does is make your page eligible for enhanced result presentations (rich snippets, FAQ accordions, breadcrumbs) that indirectly improve click-through rate. If a page has thin or low-quality content, wrapping it in perfect JSON-LD won't rescue it.
Why This Matters More Than It Used To
The docs mention "search engines and AI" almost in passing, but that second half of the sentence has gotten a lot more consequential in the last couple of years. AI systems that answer questions by summarizing web content — search engine AI overviews, browser-embedded assistants, and general-purpose crawlers building retrieval indexes — lean on structured data even more heavily than traditional search ranking ever did, because it's the fastest way for a machine to extract an unambiguous fact (a price, a rating, an author, a publish date) without having to parse prose and guess.
A page with clean, accurate JSON-LD is handing that machine a shortcut. A page without it is asking the machine to infer the same facts from HTML structure and visible text, which is strictly less reliable and more likely to produce wrong or outdated answers being surfaced to someone who never visits your site at all. If organic traffic and citation-in-AI-answers both matter to you, JSON-LD has quietly become one of the highest-leverage, lowest-effort additions you can make to a content page.
Key Takeaways
| Situation | What to do |
|---|---|
| Rendering JSON-LD at all | Plain <script type="application/ld+json">, not next/script |
| Injecting the JSON string | dangerouslySetInnerHTML, always with .replace(/</g, '\\u003c') |
| Data includes any user/CMS input | Treat the XSS escape as mandatory, not optional |
| Wanting type safety | Use schema-dts's WithContext<T> types |
| Site-wide schema (Organization, WebSite) | Render once, in the root layout |
| Page-specific schema (Product, Article, Recipe) | Render in that route's page.tsx, from the same data as the visible content |
| Verifying correctness | Rich Results Test + Schema Markup Validator, after every schema change |
JSON-LD is one of the rare pieces of modern web development that costs almost nothing to implement correctly and rewards you disproportionately when you do — a few extra lines in a Server Component, one regex-based sanitization step, and your content becomes legible to every machine trying to understand it, not just the humans reading the rendered page.


