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.

Updated Sep 28, 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

FAQPage can describe visible questions and answers, but Google retired FAQ rich results in May 2026. The pattern below illustrates vocabulary and rendering, not eligibility for the former expanding results. See Google’s documentation updates.

// 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 the schema generator to prepare supported example markup, then validate the actual published output:

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.

Keep a sample of published JSON-LD and compare it after template changes. RankCrab’s paid audit workspace remains prelaunch.

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

Make your next SEO change a useful one.

The free tools are ready now. Our paid product is still in development; join the launch list for an update when it is available.