Published 6 min read
JSON-LD Structured Data in Next.js 15
To add JSON-LD in Next.js, render a script tag with type application/ld+json from a Server Component, serialize your schema.org object with JSON.stringify and escape the less-than character. Build one @graph per page with stable @id values so Person, WebSite and BlogPosting nodes link together, type it with schema-dts, and only describe content that is visible on the page.
By Sami Ullah · #nextjs #seo #typescript
JSON-LD is how you tell search engines, in plain data, what a page is about: this is a blog post, this person wrote it, it was published on this date. Next.js does not have a special API for it, which confuses people coming from plugins that do it for them. This post shows how I add JSON-LD in Next.js 15 App Router projects, from a safe component to a typed graph, and how I test it.
Code was checked with Next.js 15 and schema-dts 1.1 in November 2026.
What is JSON-LD?#
JSON-LD (JSON for Linking Data) is a format for structured data that describes a page's entities, such as an article, a person or a product, using the schema.org vocabulary inside a script tag.
It sits next to your HTML rather than inside it, which is why Google recommends it over Microdata and RDFa. A crawler reads the script, finds "@type": "BlogPosting", and knows the headline, author and dates without guessing from the layout. Google's introduction to structured data explains which types can produce rich results.
Why do I need JSON-LD if I already have metadata?#
Metadata describes the page for previews and indexing, while JSON-LD describes the things on the page and how they relate to each other.
A meta description says "this page is about generateMetadata". JSON-LD says "this is a BlogPosting, written by the Person at this URL, published by this WebSite, on this date, inside this breadcrumb trail". That second kind of data helps search engines connect your pages to an entity, which matters for name searches and for AI answers that cite sources. It also enables rich results such as breadcrumbs, article dates and product prices.
How do I add JSON-LD to a page in the App Router?#
Render a script tag with type="application/ld+json" from a Server Component and pass the serialized object through dangerouslySetInnerHTML.
I keep this in one small component so the escaping lives in one place:
// components/json-ld.tsx
import type { Thing, WithContext, Graph } from "schema-dts";
type Props = { data: WithContext<Thing> | Graph };
export function JsonLd({ data }: Props) {
return (
<script
type="application/ld+json"
// Escape "<" so a value like "</script>" cannot close the tag early.
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
/>
);
}This is the same approach the Next.js JSON-LD guide uses. Do not use next/script for this. That component is for loading executable scripts with a strategy, and JSON-LD is data, not code.
Why do I need to escape the less-than character?#
Escaping < as \u003c stops any value in your data from closing the script tag and injecting HTML.
JSON.stringify produces valid JSON, but it does not make the JSON safe inside HTML. If a blog title or a user's display name contains the text </script>, the browser ends the script element there and treats the rest as markup. That is a stored XSS bug. The replacement keeps the JSON identical for any parser, because \u003c decodes back to the same character.
The risk is highest when values come from a CMS, a database or user profiles, because you do not control what editors paste in. Quotes and backslashes are already handled by JSON.stringify; only the tag-closing sequence needs the extra step, so keep the escaping in the component rather than trusting each caller to sanitise its input.
How do I type JSON-LD in TypeScript?#
Install schema-dts from Google and type your objects with WithContext, the specific schema type and Graph.
npm install --save-dev schema-dtsThe package only contains types, so it adds nothing to your bundle. It catches typos such as datePublised and wrong value types, and your editor autocompletes property names. It does not check Google's rich result requirements, so you still need to test the output.
How do I build a connected @graph with stable @id values?#
Put all entities for a page in one @graph array and give each one an @id that never changes, then reference those ids instead of repeating objects.
This is the pattern I use on content sites. The Person and WebSite nodes have the same @id on every page, so search engines can merge them. The page-specific node, such as BlogPosting, points at them.
// lib/schema.ts
import type { Graph } from "schema-dts";
const SITE = "https://example.com";
export const PERSON_ID = `${SITE}/#person`;
export const WEBSITE_ID = `${SITE}/#website`;
type PostInput = {
slug: string;
title: string;
description: string;
publishAt: string; // YYYY-MM-DD
updatedAt: string;
coverUrl: string;
};
export function blogPostGraph(post: PostInput): Graph {
const url = `${SITE}/blog/${post.slug}`;
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": PERSON_ID,
name: "Example Author",
url: `${SITE}/about`,
},
{
"@type": "WebSite",
"@id": WEBSITE_ID,
url: SITE,
name: "Example Studio",
publisher: { "@id": PERSON_ID },
},
{
"@type": "BlogPosting",
"@id": `${url}#article`,
headline: post.title,
description: post.description,
image: `${SITE}${post.coverUrl}`,
datePublished: post.publishAt,
dateModified: post.updatedAt,
author: { "@id": PERSON_ID },
publisher: { "@id": PERSON_ID },
isPartOf: { "@id": WEBSITE_ID },
mainEntityOfPage: url,
},
{
"@type": "BreadcrumbList",
"@id": `${url}#breadcrumb`,
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: SITE },
{ "@type": "ListItem", position: 2, name: "Blog", item: `${SITE}/blog` },
{ "@type": "ListItem", position: 3, name: post.title, item: url },
],
},
],
};
}Then the page renders it next to the content:
// app/blog/[slug]/page.tsx (excerpt)
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/json-ld";
import { blogPostGraph } from "@/lib/schema";
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();
return (
<article>
<JsonLd data={blogPostGraph(post)} />
<h1>{post.title}</h1>
</article>
);
}The @id values are URLs with fragments. They do not need to resolve to anything, but using your real domain keeps them globally unique. Pick them once and never change them, because changing an @id looks like a different entity to a crawler.
Which schema types should a portfolio or blog use?#
Most content sites need only five or six types: Person or Organization, WebSite, BlogPosting, BreadcrumbList, and sometimes FAQPage or CreativeWork.
| Page | Main type | Also include | Rich result possible |
|---|---|---|---|
| Home | WebSite | Person or Organization | Site name in results |
| About | ProfilePage | Person | None directly, helps entity understanding |
| Blog post | BlogPosting | BreadcrumbList | Article date, breadcrumbs |
| Case study | CreativeWork | BreadcrumbList | Breadcrumbs |
| Product page | Product | Offer, AggregateRating | Price, availability, stars |
| Local business | LocalBusiness | PostalAddress, OpeningHoursSpecification | Business details |
Keep each page to one main entity. A blog post is a BlogPosting, not also an Article, a NewsArticle and a TechArticle. Stacking several overlapping types does not earn extra rich results, and it makes it harder for a crawler to tell which node describes the page. If you are unsure which type fits, pick the more general one and fill in its properties well.
Dates deserve the same care. Use ISO 8601 strings, include dateModified only when the content really changed, and make sure the visible "Updated on" date on the page matches it. Search engines compare the two, and a dateModified that moves on every build without a visible change teaches them to ignore it.
Do not mark up things that are not there. A Review or AggregateRating without real, visible reviews breaks Google's structured data guidelines and can lead to a manual action.
Should FAQPage schema match the visible FAQ?#
Yes, the questions and answers in FAQPage JSON-LD must match the FAQ text on the page exactly.
Google's guidelines say structured data must represent the main content of the page and be visible to users. The safest way to guarantee that is to generate both from one source. On content sites, I keep FAQ items in the post's frontmatter, render them as details and summary elements, and build the JSON-LD from the same array:
// lib/schema.ts (continued)
import type { FAQPage, WithContext } from "schema-dts";
export function faqSchema(items: { q: string; a: string }[]): WithContext<FAQPage> {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.q,
acceptedAnswer: { "@type": "Answer", text: item.a },
})),
};
}Since August 2023, Google only shows FAQ rich results for a small group of authoritative government and health sites. I still include the markup when the FAQ is on the page, because it is accurate data that other engines and AI tools can read.
How do I test JSON-LD in Next.js?#
Run each page type through Google's Rich Results Test and the Schema Markup Validator, then add a unit test so the graph cannot break silently.
- Deploy a preview and paste a URL into the Rich Results Test. It shows which rich result types were detected and any missing required fields.
- Paste the same URL into the Schema Markup Validator, which checks against the full schema.org vocabulary rather than only Google's subset.
- After launch, watch the Enhancements reports in Search Console for new errors.
- Add a test that builds the graph for a sample post and checks the ids and dates.
// tests/schema.test.ts
import { describe, expect, it } from "vitest";
import { blogPostGraph, PERSON_ID } from "@/lib/schema";
describe("blogPostGraph", () => {
it("links the post to the person by @id", () => {
const graph = blogPostGraph({
slug: "hello",
title: "Hello",
description: "A test post",
publishAt: "2027-01-14",
updatedAt: "2027-01-14",
coverUrl: "/blog/hello/cover.png",
});
const json = JSON.stringify(graph);
expect(json).toContain(`"author":{"@id":"${PERSON_ID}"}`);
expect(json).toContain(`"datePublished":"2027-01-14"`);
});
});The same graph approach is planned for the Markdown blog engine concept, and the product version of it, with Product and Offer nodes, applies to the e-commerce storefront concept. You can view the page source of a product page in my e-commerce storefront demo to see Product JSON-LD rendered from a Server Component. For everything else a page needs, see the Next.js App Router SEO checklist, and for more of my Next.js work, the Next.js skill page.
What should you do first?#
Add the JsonLd component with the escaping, then ship a Person or Organization node and a WebSite node with fixed @id values on your home page. Run the home page through the Rich Results Test. Once that passes, add BlogPosting and BreadcrumbList to your post template, reusing the same ids. Leave FAQ, Product and Review markup until the matching content is actually visible on those pages.
Frequently asked questions
Does Next.js have a built-in API for JSON-LD?
No. The Metadata API covers title, meta and link tags, but not JSON-LD. The official Next.js guide recommends rendering a script tag with type application/ld+json inside your page or layout component, using dangerouslySetInnerHTML with the JSON string. Because it is rendered by a Server Component, the data is in the initial HTML and crawlers can read it without running JavaScript.
Should JSON-LD go in the head or the body?
Either works. Google reads JSON-LD from anywhere in the HTML document, and schema.org does not require the head. In the App Router, it is simplest to render the script inside the page component, which puts it in the body. What matters is that it is in the server-rendered HTML, not injected later by client-side JavaScript after hydration.
Do FAQ rich results still show in Google?
Since August 2023, Google shows FAQ rich results only for a small set of well-known government and health sites. For most sites the FAQPage markup no longer produces the expandable result. It is still valid schema.org data that other search engines and AI tools can read, so I keep it when the FAQ is visible on the page and matches it word for word.
What is the @id property in JSON-LD for?
The @id property gives a node a stable identifier, usually a URL with a fragment such as https://example.com/#person. Other nodes can then reference it with an object containing only that @id instead of repeating the full data. This links your BlogPosting author, WebSite publisher and Person profile into one connected graph that search engines can merge across pages.
Where I used this
- Case study: Markdown Blog Engine with a Hand-written Parser · ▶ live demo
- Case study: E-commerce Storefront with Test-Mode Checkout · ▶ live demo
- Skill: Next.js