JSON-LD is a JSON format that describes what a page is about, such as a recipe, a product, or an article, in a way search engines can read. In the App Router you add it by rendering a script element from a Server Component, using the same data the page already fetched. There is no metadata field for it.
The pattern below matches the current Next.js recommendation and was verified with Next.js 16.3 in an App Router project. The script is rendered on the server and appears in the initial HTML.
The smallest working example
Build the JSON-LD object next to the content it describes, then render it inside the page. The serialized string passes through an escape before it reaches the DOM.
// app/recipes/[slug]/page.tsx
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Recipe',
name: recipe.name,
totalTime: `PT${recipe.minutes}M`,
}That object is plain data, and the context and type fields are what make it schema.org markup rather than an arbitrary blob. Every type has its own expected fields, which the schema.org reference documents.
Rendering it takes one element. The dangerous-sounding prop is required here because the browser needs raw JSON inside the tag rather than escaped HTML text.
// app/recipes/[slug]/page.tsx
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>The rendered page now contains that script inside the article element, with the JSON as its text content. React does not hoist or deduplicate inline scripts, so the block stays exactly where you render it, and Google accepts JSON-LD in the body as well as the head.
Why the escape is not optional
The values in a real payload come from a database or CMS, so they are untrusted input arriving inside an HTML document. Without the escape, a value containing a closing script tag ends the block early and everything after it is parsed as markup.
"description":"A <script>alert(1)</script> test of escaping"That is the actual rendered output for a description containing a script tag. The less-than characters became unicode escapes, which JSON parsers read as the original characters while the HTML parser sees nothing that could close the tag.
Serializing straight into the tag is a cross-site scripting hole whenever any field is editable by a user. If you prefer a library, use a serializer built for embedding in HTML instead of removing the step.
Building it from route data
A useful example needs the real record, which means fetching it in the Server Component and reusing the same deduplicated function the rest of the route uses. This is the page file for the dynamic recipe route.
// app/recipes/[slug]/page.tsx
export default async function Page({ params }: PageProps<'/recipes/[slug]'>) {
const recipe = await getRecipe((await params).slug)
if (!recipe) notFound()
const json = JSON.stringify(jsonLdFor(recipe)).replace(/</g, '\\u003c')
return (
<article>
<h1>{recipe.name}</h1>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: json }} />
</article>
)
}The imports are the not-found helper and your own data function, as in any other dynamic route. The page renders the visible recipe and the JSON-LD from one record, so the two can never disagree, which matters because Google's guidelines require the markup to describe content the visitor can actually see.
A missing record calls the not-found helper before any markup is built, so a 404 route never ships structured data describing something that does not exist.
Metadata and JSON-LD are separate systems describing the same page. The title and description come from generateMetadata, while the schema block adds machine-readable detail underneath it, and the SEO checklist shows where both sit in a release.
Typing the payload
The object is easy to get subtly wrong, since a misspelled field is silently ignored rather than reported. A community package supplies types for schema.org shapes.
// app/lib/recipes.ts
import type { Recipe, WithContext } from 'schema-dts'
export function jsonLdFor(recipe: { name: string }): WithContext<Recipe> {
return { '@context': 'https://schema.org', '@type': 'Recipe', name: recipe.name }
}TypeScript now rejects a field that is not part of that type, and completion shows what the type accepts. This package is maintained by the community rather than by Next.js, so treat it as a convenience rather than a requirement.
Keep the builder in a server module next to the data access. It has no reason to run in the browser, and keeping it on the server means it can read fields the page never renders.
Validating what you shipped
The object in your editor is not evidence. Read the rendered script from a production build, then run the URL through a validator.
npx next build && npx next start
curl -s http://localhost:3000/recipes/sourdough | grep -o 'application/ld+json'Finding the tag confirms the JSON-LD is server-rendered rather than added later by client JavaScript. After that, the Rich Results Test shows which result types Google can derive, and the Schema Markup Validator checks the shape against schema.org itself.
Check one route per type rather than every page. Pages of the same type share the builder, so a fault in one is a fault in all of them.
Structured data is a description, not a promise of rich results. Google decides whether to use it, and markup that contradicts the page is the fastest way to be ignored.
Common mistakes
Each of these JSON-LD mistakes validates as JSON and still fails the purpose.
- Serializing untrusted values without escaping the less-than character.
- Describing content the page does not show, such as ratings that appear nowhere on screen.
- Adding the block through a Client Component effect, so the first HTML response has nothing in it.
- Reaching for the Script component, which is built for executable JavaScript rather than data.
- Shipping several conflicting types for one page and leaving a crawler to pick.
- Copying a JSON-LD block between routes and leaving a hardcoded name or URL from the page it came from.
Rune AI
Key Insights
- Render a script element with the ld+json type from a Server Component page or layout.
- Escape the serialized payload so untrusted values cannot close the script tag.
- Build the object from the same record the page renders, so the markup matches the visible content.
- React does not hoist inline scripts, so the block stays where you render it, which search engines accept.
- Validate the output with the Rich Results Test or the Schema Markup Validator.
Frequently Asked Questions
Does the JSON-LD script have to be in the head?
Should I use the Script component for this?
Why do I have to escape the JSON?
Does the Metadata API have a JSON-LD field?
Conclusion
JSON-LD in the App Router is a script element rendered by a Server Component, built from the same data the page renders. Escape the payload before it reaches the DOM, describe only what the visitor can actually see, and validate the result with a structured data testing tool rather than trusting the object you wrote.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.