JSON-LD in Next.js
Inject structured data by rendering a <script type="application/ld+json"> tag inside your Server Component. Because it's server-rendered, Googlebot sees it immediately without executing JS.
Structured Data (JSON-LD)
Search engines don't just read your layout HTML; they try to decipher what your page actually represents. While metadata tags provide simple headers, structured data (specifically JSON-LD) provides a standardized, machine-readable vocabulary to define entities like articles, products, events, and reviews. By injecting structured data, you unlock "Rich Snippets" in Google Search results—including review stars, recipe timers, product pricing, and FAQ dropdowns—which dramatically increases organic click-through rates (CTR).
1. What is JSON-LD and Why Do We Prefer It?
JSON-LD (JavaScript Object Notation for Linked Data) is a method of encoding Linked Data using JSON. Unlike older markup strategies like Microdata or RDFa, which require you to inject attributes directly into your HTML tags (bloating your CSS styles and breaking component nesting), JSON-LD is completely decoupled from your visual layout.
It is placed inside a single, simple script tag in your document: <script type="application/ld+json">. Search engines read this block to construct their knowledge graph without relying on parsing user-facing CSS tags.
2. The Server-First JSON-LD Pattern in Next.js 15
In Next.js 15 and React 19, the recommended pattern is to inject JSON-LD directly inside your Server Component layouts or page files. Because it is server-rendered, crawlers see the structured data on the initial HTTP response, meaning search engines do not need to run browser JavaScript engines to extract it.
We construct our schema as a plain JavaScript object, serialize it with JSON.stringify(), and render it inside a script tag using dangerouslySetInnerHTML. Next.js automatically dedupes and hoists script tags when appropriate.
3. Defining Type-Safe Schemas with schema-dts
To prevent naming errors or missing mandatory properties, you should use the schema-dts package. This package provides complete TypeScript definitions for all Schema.org vocabularies, ensuring compiler-level validation of your structures.
Install it in your workspace using: npm install -D schema-dts.
Here is how you use it to type-check a standard schema:
import { Article, WithContext } from 'schema-dts';
const jsonLd: WithContext<Article> = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Introduction to Next.js 15',
datePublished: '2026-07-07T00:00:00.000Z',
author: {
'@type': 'Person',
name: 'Qasim Ali',
},
};
4. Implementing a Basic Organization Schema
The Organization schema defines your company's core identity—such as name, logo, social profiles, and customer support channels. Typically, this schema is placed inside your root layout component (app/layout.tsx) so it applies site-wide.
// app/layout.tsx
import { Organization, WithContext } from 'schema-dts';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const organizationSchema: WithContext<Organization> = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'ProgrUmar Academy',
url: 'https://progrumar.com',
logo: 'https://progrumar.com/logo.png',
sameAs: [
'https://twitter.com/progrumar',
'https://github.com/progrumar',
],
};
return (
<html lang="en">
<body>
{/* Inject JSON-LD directly into the HTML tree */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
/>
{children}
</body>
</html>
);
}
5. Dynamic Article and Blog Schemas
Dynamic pages, such as blog posts or courses, must render their own individualized schemas based on runtime data. In Next.js 15, we fetch the data in our async Server Component, construct the schema object, and return it alongside our layout nodes.
// app/blog/[slug]/page.tsx
import { BlogPosting, WithContext } from 'schema-dts';
import { notFound } from 'next/navigation';
interface BlogPost {
title: string;
excerpt: string;
publishedAt: string;
coverImage: string;
authorName: string;
}
async function getPostData(slug: string): Promise<BlogPost | null> {
const res = await fetch('https://api.progrumar.com/posts/' + slug);
if (!res.ok) return null;
return res.json();
}
export default async function BlogPostPage({ params }) {
const { slug } = await params;
const post = await getPostData(slug);
if (!post) notFound();
const blogSchema: WithContext<BlogPosting> = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
datePublished: post.publishedAt,
image: post.coverImage,
author: {
'@type': 'Person',
name: post.authorName,
url: 'https://progrumar.com/authors/' + post.authorName.toLowerCase(),
},
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogSchema) }}
/>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
</main>
);
}
6. Product, Offers, and Aggregate Rating Schemas
If your platform sells courses or digital products, rich results containing prices, stock availability, and user rating stars can heavily influence click rates.
A product schema requires nested structures such as Offers and optional AggregateRating:
// app/products/[id]/page.tsx
import { Product, WithContext } from 'schema-dts';
export default async function ProductPage({ params }) {
const { id } = await params;
const productSchema: WithContext<Product> = {
'@context': 'https://schema.org',
'@type': 'Product',
name: 'Advanced Next.js Mastery Course',
image: 'https://progrumar.com/assets/nextjs-course.png',
description: 'Master App Router, caching patterns, and high-performance server architectures.',
sku: 'nextjs-15-mastery',
brand: {
'@type': 'Brand',
name: 'ProgrUmar',
},
offers: {
'@type': 'Offer',
price: '49.00',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
url: 'https://progrumar.com/products/nextjs-15-mastery',
priceValidUntil: '2027-12-31',
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.9',
reviewCount: '154',
},
};
return (
<section>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }}
/>
<h2>Next.js Course</h2>
<p>Rating: 4.9 out of 154 reviews</p>
</section>
);
}
7. Constructing BreadcrumbList Schema
A BreadcrumbList schema signals your website's navigational hierarchy to Google. Instead of displaying a raw URL string in search results, Google will render a clean, step-by-step navigation trail (e.g., ProgrUmar > Courses > Next.js).
// components/Breadcrumbs.tsx
import { BreadcrumbList, WithContext } from 'schema-dts';
export default function Breadcrumbs() {
const breadcrumbsSchema: WithContext<BreadcrumbList> = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: 'https://progrumar.com',
},
{
'@type': 'ListItem',
position: 2,
name: 'Courses',
item: 'https://progrumar.com/courses',
},
{
'@type': 'ListItem',
position: 3,
name: 'Next.js',
item: 'https://progrumar.com/courses/nextjs',
},
],
};
return (
<nav aria-label="Breadcrumb">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbsSchema) }}
/>
{/* Visual Breadcrumb markup here */}
</nav>
);
}
8. Local Business & SiteNavigationElement Schemas
For local services or brick-and-mortar storefronts, a LocalBusiness schema displays crucial geographic features, phone links, and business hours within search profiles. Additionally, you can map primary navigation headers using SiteNavigationElement to hint sitelinks to Google.
// components/LocalBusinessSchema.tsx
import { LocalBusiness, WithContext } from 'schema-dts';
export default function LocalBusinessSchema() {
const businessSchema: WithContext<LocalBusiness> = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'ProgrUmar Consulting',
image: 'https://progrumar.com/office-cover.png',
telephone: '+1-555-555-5555',
email: 'info@progrumar.com',
address: {
'@type': 'PostalAddress',
streetAddress: '123 Developer Lane',
addressLocality: 'Tech City',
addressRegion: 'CA',
postalCode: '94043',
addressCountry: 'US',
},
geo: {
'@type': 'GeoCoordinates',
latitude: 37.4220,
longitude: -122.0841,
},
openingHoursSpecification: [
{
'@type': 'OpeningHoursSpecification',
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
opens: '09:00',
closes: '18:00',
},
],
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(businessSchema) }}
/>
);
}
9. Dynamic Security: Mitigating XSS Vulnerabilities
Because structured data strings are serialized directly into your raw HTML markup using dangerouslySetInnerHTML, rendering unvalidated, user-submitted content within schema objects poses a major cross-site scripting (XSS) risk.
If a user inputs a payload like </script><script>alert('XSS')</script> as their author name or course review title, it will break out of the JSON-LD script wrapper and run directly inside the victim's browser context.
To solve this, serialize your data objects using a library like serialize-javascript or sanitize JSON strings by escaping HTML characters manually:
// utils/seo.ts
export function safeJsonStringify(data: Record<string, any>): string {
const rawString = JSON.stringify(data);
// Replace vulnerable script brackets to prevent script breakout
return rawString.replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
}
Use this utility inside your components:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonStringify(blogSchema) }}
/>
10. Common Gotchas
- Malformed JSON Formats: Always run JSON-LD blocks through validators. Simple structural issues (like a trailing comma after the final array property) will cause Google's parser to dump the entire block.
- Mismatching Visual Content: Schema definitions must reflect the actual visual data rendered on the page. If your JSON-LD claims a product price is $49, but the visual text displays $99, search crawlers will flag this as deceitful behavior, leading to search penalties.
-
Invalid Date Formats: Date fields (like
datePublishedordateModified) require ISO 8601 formats (e.g.YYYY-MM-DDorYYYY-MM-DDThh:mm:ssTZD). Relative formats like '2 hours ago' are invalid. -
Missing Crucial Fields: Specific schemas require core variables. For instance, dynamic Product objects will fail verification guidelines if they lack nested
offersorbrandspecifications.
Key Takeaways
- JSON-LD is decoupled from client styling, making it lightweight and highly maintainable.
- Embed schema details directly within async Server Components to server-render them on the first response cycle.
- Leverage
schema-dtsto gain typescript validation across all schema models. - Always wrap data serializations with an HTML-escaping utility to defend against cross-site scripting (XSS) script breakouts.
- Verify structured data files using Google's official Rich Results Test suite before running scripts in production.
Structuring your metadata and business profiles ensures search engine crawlers understand what you offer. But to rank in top positions, your website must be fast and performant. In the next lesson, we will cover Core Web Vitals & Performance Auditing, looking at how to measure, track, and optimize critical metrics like LCP, CLS, and INP to achieve high-performance auditing scores.