TYPESCRIPTseoadvanced

Next.js 15 Structured Data and Rich Results

Implement dynamic JSON-LD structured data in Next.js 15 App Router with Server Components and Metadata API

Faisal Yaqoob
#nextjs#react#schema#seo#structured-data#json-ld#typescript#app-router#server-components#rich-snippets
Share this snippet:

Code

typescript
1// components/JsonLd.tsx
2// Reusable, type-safe JSON-LD component for any schema type
3
4import type { Thing, WithContext } from 'schema-dts';
5
6interface JsonLdProps<T extends Thing> {
7 data: WithContext<T>;
8}
9
10export default function JsonLd<T extends Thing>({ data }: JsonLdProps<T>) {
11 return (
12 <script
13 type="application/ld+json"
14 dangerouslySetInnerHTML={{
15 __html: JSON.stringify(data),
16 }}
17 />
18 );
19}

Next.js 15 Structured Data and Rich Results

Implement production-ready JSON-LD structured data in Next.js 15 using the App Router, Server Components, and the Metadata API. This modern approach leverages React Server Components for zero-JavaScript schema injection and type-safe schema generation with schema-dts.

Type-Safe Schema Component

// components/JsonLd.tsx
// Reusable, type-safe JSON-LD component for any schema type

import type { Thing, WithContext } from 'schema-dts';

interface JsonLdProps<T extends Thing> {
  data: WithContext<T>;
}

export default function JsonLd<T extends Thing>({ data }: JsonLdProps<T>) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(data),
      }}
    />
  );
}

Dynamic Article Schema with Metadata

// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import JsonLd from '@/components/JsonLd';
import type { Article, WithContext } from 'schema-dts';

interface BlogPost {
  title: string;
  description: string;
  content: string;
  author: string;
  publishedAt: string;
  updatedAt: string;
  image: string;
  slug: string;
  category: string;
  tags: string[];
  readTime: number;
}

// Fetch your blog post data
async function getPost(slug: string): Promise<BlogPost | null> {
  // Replace with your data source (CMS, database, MDX, etc.)
  const res = await fetch(`${process.env.API_URL}/posts/${slug}`, {
    next: { revalidate: 3600 }, // ISR: revalidate every hour
  });
  if (!res.ok) return null;
  return res.json();
}

// Generate dynamic metadata
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};

  const url = `${process.env.NEXT_PUBLIC_SITE_URL}/blog/${slug}`;

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      url,
      type: 'article',
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      authors: [post.author],
      images: [{ url: post.image, width: 1200, height: 630, alt: post.title }],
      tags: post.tags,
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.description,
      images: [post.image],
    },
    alternates: {
      canonical: url,
    },
  };
}

// Page component with schema
export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!;
  const postUrl = `${siteUrl}/blog/${slug}`;

  const articleSchema: WithContext<Article> = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.description,
    image: post.image,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    url: postUrl,
    author: {
      '@type': 'Person',
      name: post.author,
      url: `${siteUrl}/about`,
    },
    publisher: {
      '@type': 'Organization',
      name: 'Your Site Name',
      logo: {
        '@type': 'ImageObject',
        url: `${siteUrl}/logo.png`,
      },
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': postUrl,
    },
    articleSection: post.category,
    keywords: post.tags.join(', '),
    wordCount: post.content.split(/\s+/).length,
  };

  return (
    <>
      <JsonLd data={articleSchema} />
      <article>
        <h1>{post.title}</h1>
        {/* Your article content */}
      </article>
    </>
  );
}

Multi-Schema Layout (Root Layout)

// app/layout.tsx
import JsonLd from '@/components/JsonLd';
import type { Organization, WebSite, WithContext } from 'schema-dts';

const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://yoursite.com';

const organizationSchema: WithContext<Organization> = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  name: 'Your Company Name',
  url: siteUrl,
  logo: `${siteUrl}/logo.png`,
  description: 'Your company description for SEO.',
  contactPoint: {
    '@type': 'ContactPoint',
    telephone: '+1-555-123-4567',
    contactType: 'customer service',
    email: 'hello@yoursite.com',
  },
  sameAs: [
    'https://twitter.com/yourhandle',
    'https://github.com/yourusername',
    'https://linkedin.com/company/yourcompany',
  ],
};

const websiteSchema: WithContext<WebSite> = {
  '@context': 'https://schema.org',
  '@type': 'WebSite',
  name: 'Your Site Name',
  url: siteUrl,
  potentialAction: {
    '@type': 'SearchAction',
    target: {
      '@type': 'EntryPoint',
      urlTemplate: `${siteUrl}/search?q={search_term_string}`,
    },
    'query-input': 'required name=search_term_string',
  } as any,
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <JsonLd data={organizationSchema} />
        <JsonLd data={websiteSchema} />
      </head>
      <body>{children}</body>
    </html>
  );
}
// lib/schema/breadcrumb.ts
import type { BreadcrumbList, WithContext } from 'schema-dts';

interface BreadcrumbItem {
  name: string;
  url: string;
}

export function generateBreadcrumbSchema(
  items: BreadcrumbItem[]
): WithContext<BreadcrumbList> {
  return {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: items.map((item, index) => ({
      '@type': 'ListItem' as const,
      position: index + 1,
      name: item.name,
      item: item.url,
    })),
  };
}

// Usage in any page:
// const breadcrumbs = generateBreadcrumbSchema([
//   { name: 'Home', url: 'https://yoursite.com' },
//   { name: 'Blog', url: 'https://yoursite.com/blog' },
//   { name: 'Post Title', url: 'https://yoursite.com/blog/post-slug' },
// ]);

FAQ Schema Generator

// lib/schema/faq.ts
import type { FAQPage, WithContext } from 'schema-dts';

interface FAQItem {
  question: string;
  answer: string;
}

export function generateFAQSchema(
  faqs: FAQItem[]
): WithContext<FAQPage> {
  return {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: faqs.map((faq) => ({
      '@type': 'Question' as const,
      name: faq.question,
      acceptedAnswer: {
        '@type': 'Answer' as const,
        text: faq.answer,
      },
    })),
  };
}

// Usage:
// const faqSchema = generateFAQSchema([
//   { question: 'What is Next.js?', answer: 'Next.js is a React framework...' },
//   { question: 'Is Next.js free?', answer: 'Yes, Next.js is open source...' },
// ]);

Product Schema for E-Commerce

// lib/schema/product.ts
import type { Product, WithContext } from 'schema-dts';

interface ProductData {
  name: string;
  description: string;
  image: string | string[];
  sku: string;
  brand: string;
  price: number;
  currency: string;
  availability: 'InStock' | 'OutOfStock' | 'PreOrder';
  rating?: number;
  reviewCount?: number;
  url: string;
}

export function generateProductSchema(
  product: ProductData
): WithContext<Product> {
  const schema: WithContext<Product> = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.image,
    sku: product.sku,
    brand: {
      '@type': 'Brand',
      name: product.brand,
    },
    offers: {
      '@type': 'Offer',
      url: product.url,
      priceCurrency: product.currency,
      price: product.price,
      availability: `https://schema.org/${product.availability}`,
      itemCondition: 'https://schema.org/NewCondition',
    },
  };

  if (product.rating && product.reviewCount) {
    schema.aggregateRating = {
      '@type': 'AggregateRating',
      ratingValue: product.rating,
      bestRating: 5,
      reviewCount: product.reviewCount,
    };
  }

  return schema;
}

Schema Validation Utility

// lib/schema/validate.ts
// Runtime schema validation helper

interface SchemaValidation {
  isValid: boolean;
  errors: string[];
  warnings: string[];
}

export function validateSchema(schema: Record<string, any>): SchemaValidation {
  const errors: string[] = [];
  const warnings: string[] = [];

  // Check required fields
  if (!schema['@context']) errors.push('Missing @context');
  if (!schema['@type']) errors.push('Missing @type');

  // Type-specific validation
  if (schema['@type'] === 'Article') {
    if (!schema.headline) errors.push('Article missing headline');
    if (!schema.author) errors.push('Article missing author');
    if (!schema.datePublished) errors.push('Article missing datePublished');
    if (!schema.image) warnings.push('Article missing image (recommended)');
    if (!schema.dateModified) warnings.push('Article missing dateModified');
  }

  if (schema['@type'] === 'Product') {
    if (!schema.name) errors.push('Product missing name');
    if (!schema.offers) errors.push('Product missing offers');
    if (!schema.image) warnings.push('Product missing image');
    if (!schema.aggregateRating) warnings.push('Product has no ratings');
  }

  return {
    isValid: errors.length === 0,
    errors,
    warnings,
  };
}

Install schema-dts

# Install schema-dts for type-safe schema definitions
npm install schema-dts

# schema-dts provides TypeScript types for all Schema.org types
# No runtime code — types only, zero bundle impact

Best Practices

  • Use Server Components — schema is injected at build/server time with zero client-side JavaScript
  • Install schema-dts — provides TypeScript types for all 800+ Schema.org types
  • Reuse JsonLd component — single component handles all schema types
  • Generate metadata and schema together — use the same data source for consistency
  • Use ISR (revalidate) — keep schema data fresh without rebuilding the entire site
  • Validate at build time — add schema validation to your CI/CD pipeline
  • Place schema in <head> — Next.js Server Components in layout render in the head

Features

  • Zero Client JS: Server Components inject schema without JavaScript overhead
  • Type-Safe: Full TypeScript support with schema-dts types
  • Reusable Generators: Modular schema generators for any content type
  • Metadata API Integration: Combine schema with Next.js Metadata API
  • ISR Compatible: Works with Incremental Static Regeneration
  • Multi-Schema Support: Multiple schema types per page via layout composition

Dependencies

  • next
  • react
  • schema-dts

Related Snippets