SEO for Next.js

Next.js Structured Data: The Type-Safe JSON-LD Pattern

App Router has no built-in JSON-LD support. Here's the type-safe pattern that emits valid schema from a server component.

Published May 24, 20266 min readBy RankCrab Team

The App Router's Metadata API covers title, description, Open Graph, canonical URLs, and robots — but it doesn't cover structured data. JSON-LD lives in a <script type="application/ld+json"> tag, and you have to put it there yourself.

The pattern: a Server Component that renders a <Script> tag with your JSON-LD payload. This keeps structured data out of client bundles, runs at render time with full access to server-side data, and gives you TypeScript type safety over every schema property.

Why the Metadata API Doesn't Cover JSON-LD

The Metadata type in next doesn't have a jsonLd field. This is a deliberate omission — the Metadata API is designed for <head> tags that affect crawling, social sharing, and browser behavior. JSON-LD is schema markup for search engines, and its structure varies too widely across schema types to fit in a typed metadata object.

The right primitive is the <Script> component from next/script:

import Script from 'next/script'

export default function Page() {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: 'My Article Title',
  }

  return (
    <>
      <Script
        id="json-ld"
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* page content */}</article>
    </>
  )
}

In a Server Component, this renders the <script> tag in the HTML on the server — exactly what you want.

Adding Type Safety with schema-dts

The schema-dts package provides TypeScript types for every schema.org type. Install it:

npm install schema-dts

Then build a typed helper:

// lib/json-ld.ts
import type { Thing, WithContext } from 'schema-dts'

export function jsonLd<T extends Thing>(data: WithContext<T>): string {
  return JSON.stringify(data)
}

This helper accepts any WithContext<T> value and returns a JSON string. The TypeScript type ensures you include required fields and get autocomplete on optional ones.

Article Schema

For blog posts and editorial content:

// app/blog/[slug]/page.tsx
import Script from 'next/script'
import { jsonLd } from '@/lib/json-ld'
import type { Article } from 'schema-dts'

type Props = {
  params: Promise<{ slug: string }>
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)

  const articleSchema = jsonLd<Article>({
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    image: `https://yourdomain.com/blog/${slug}/opengraph-image`,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: post.author,
      url: `https://yourdomain.com/authors/${post.authorSlug}`,
    },
    publisher: {
      '@type': 'Organization',
      name: 'Your Site Name',
      logo: {
        '@type': 'ImageObject',
        url: 'https://yourdomain.com/logo.png',
      },
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://yourdomain.com/blog/${slug}`,
    },
  })

  return (
    <>
      <Script
        id="article-schema"
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: articleSchema }}
      />
      <article>{/* render post.content */}</article>
    </>
  )
}

FAQ Schema

FAQ schema can trigger rich results in Google — an expandable list of questions and answers directly in the SERP. Use it on pages that have a genuine FAQ section:

// components/faq-section.tsx
import Script from 'next/script'
import { jsonLd } from '@/lib/json-ld'
import type { FAQPage } from 'schema-dts'

type FAQItem = {
  question: string
  answer: string
}

type Props = {
  items: FAQItem[]
}

export function FAQSection({ items }: Props) {
  const faqSchema = jsonLd<FAQPage>({
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: items.map((item) => ({
      '@type': 'Question',
      name: item.question,
      acceptedAnswer: {
        '@type': 'Answer',
        text: item.answer,
      },
    })),
  })

  return (
    <>
      <Script
        id="faq-schema"
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: faqSchema }}
      />
      <section>
        <h2>Frequently Asked Questions</h2>
        {items.map((item) => (
          <details key={item.question}>
            <summary>{item.question}</summary>
            <p>{item.answer}</p>
          </details>
        ))}
      </section>
    </>
  )
}

Breadcrumb schema helps Google display your site hierarchy in search results:

// components/breadcrumbs.tsx
import Script from 'next/script'
import Link from 'next/link'
import { jsonLd } from '@/lib/json-ld'
import type { BreadcrumbList } from 'schema-dts'

type Crumb = {
  name: string
  href: string
}

type Props = {
  items: Crumb[]
}

export function Breadcrumbs({ items }: Props) {
  const breadcrumbSchema = jsonLd<BreadcrumbList>({
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: items.map((item, index) => ({
      '@type': 'ListItem',
      position: index + 1,
      name: item.name,
      item: `https://yourdomain.com${item.href}`,
    })),
  })

  return (
    <>
      <Script
        id="breadcrumb-schema"
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: breadcrumbSchema }}
      />
      <nav aria-label="Breadcrumb">
        <ol>
          {items.map((item, index) => (
            <li key={item.href}>
              {index < items.length - 1 ? (
                <Link href={item.href}>{item.name}</Link>
              ) : (
                <span aria-current="page">{item.name}</span>
              )}
            </li>
          ))}
        </ol>
      </nav>
    </>
  )
}

Multiple Schemas on One Page

Some pages need more than one schema type — a blog post might have Article schema, BreadcrumbList schema, and FAQ schema. You can emit multiple <Script> tags, one per schema:

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)

  return (
    <>
      <Script
        id="article-schema"
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: jsonLd<Article>({
          '@context': 'https://schema.org',
          '@type': 'Article',
          headline: post.title,
          // ...
        }) }}
      />
      <Script
        id="breadcrumb-schema"
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: jsonLd<BreadcrumbList>({
          '@context': 'https://schema.org',
          '@type': 'BreadcrumbList',
          // ...
        }) }}
      />
      <article>{/* content */}</article>
    </>
  )
}

Each <Script> needs a unique id prop. Next.js uses this to deduplicate scripts.

Generating Schema from Your CMS

If your content comes from a CMS, generate schema dynamically from the CMS response:

// lib/schema-builders.ts
import type { Article, WithContext } from 'schema-dts'

export function buildArticleSchema(post: {
  title: string
  excerpt: string
  slug: string
  publishedAt: string
  updatedAt: string
  authorName: string
  authorSlug: string
}): WithContext<Article> {
  return {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    image: `https://yourdomain.com/blog/${post.slug}/opengraph-image`,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: post.authorName,
      url: `https://yourdomain.com/authors/${post.authorSlug}`,
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://yourdomain.com/blog/${post.slug}`,
    },
  }
}

This pattern keeps schema construction testable and reusable across page types.

Validating Your Schema

Use RankCrab's schema generator to build and validate schema markup before or after implementation:

Free tool
Schema markup generator
Generate valid JSON-LD in 60 seconds — no signup.
Try it

For more on schema types and which ones produce rich results, see How to Add Schema Markup to Your Next.js App Router Site.

For a full audit of your structured data — including required fields, type mismatches, and missing schema on high-value pages — run the 80-check audit.

This article is part of the SEO for Next.js cluster. Related reading:

Ship optimized content this afternoon.

7-day trial. $29 when it converts. Cancel from the dashboard the second it stops earning its keep.