DEV Community

Cover image for Next.js SEO: Adding JSON-LD Structured Data (schema.org)
Nayan Kyada
Nayan Kyada

Posted on Edited on Originally published at nayankyada.com

Next.js SEO: Adding JSON-LD Structured Data (schema.org)

Structured data is one of the few SEO levers that still has a clear, measurable payoff: rich results in Google Search — article details, review stars, breadcrumb trails — that can lift click-through rate without changing your rank. If you've built Next.js App Router projects but haven't wired up JSON-LD yet, this guide walks through exactly how to do it, which schemas matter most, and what mistakes to avoid.

Why structured data matters for organic traffic

Google uses JSON-LD (and Microdata, but ignore that) to understand the type of content on a page, not just the words. A page that says "How long does it take to build a headless site?" reads as an FAQ to a crawler only if you mark it up correctly. Markup makes the type of content explicit. Note that the FAQ accordion on the SERP is gone: Google deprecated FAQ rich results and stopped showing them on May 7, 2026, so FAQPage markup no longer earns extra space in Google results.

The payoff is asymmetric. Adding structured data takes maybe two hours per schema type on a Next.js site. The risk of a penalty for getting it wrong is essentially zero (Google ignores invalid markup; it doesn't punish it). The upside is a richer snippet that can noticeably lift CTR on the queries where Google shows one.

This guide covers four schemas that cover most content sites:

  • BlogPosting — individual blog or article pages
  • BreadcrumbList — navigation trail shown in the SERP URL (desktop results only, since January 2025)
  • FAQPage — Q&A content (no longer produces a Google rich result, but still valid schema.org)
  • Person — author or about pages, useful for E-E-A-T signals

How JSON-LD works in Next.js App Router

JSON-LD is a <script type="application/ld+json"> tag in your page's HTML (it doesn't have to be in <head>). In the App Router, the right place for it is inside the page or layout component itself — not in generateMetadata. The Metadata API handles <title> and <meta> tags; JSON-LD is a separate <script> tag you render directly.

Create a tiny reusable component:

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

interface JsonLdProps {
  schema: WithContext<Thing>
}

export function JsonLd({ schema }: JsonLdProps) {
  return (
    <script
      type="application/ld+json"
      // escape `<` so CMS content can't break out of the script tag (as in the Next.js docs)
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema).replace(/</g, '\\u003c') }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Install schema-dts for TypeScript types: npm i schema-dts. It gives you typed autocompletion for every schema.org type — saves a lot of typo-debugging.

Then in any Server Component page, import and render it:

// app/blog/[slug]/page.tsx
import { JsonLd } from '@/components/json-ld'
import type { WithContext, BlogPosting, BreadcrumbList } from 'schema-dts'

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
  // params is a Promise in Next.js 15+
  const { slug } = await params
  // Fetch your post data however you do it — Sanity, MDX, etc.
  const post = await getPost(slug)

  const blogPostingSchema: WithContext<BlogPosting> = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt ?? post.publishedAt,
    author: {
      '@type': 'Person',
      name: post.author.name,
      url: `https://yourdomain.com/about`,
    },
    image: post.coverImage,
    url: `https://yourdomain.com/blog/${post.slug}`,
    publisher: {
      '@type': 'Organization',
      name: 'Your Site Name',
      logo: {
        '@type': 'ImageObject',
        url: 'https://yourdomain.com/logo.png',
      },
    },
  }

  const breadcrumbSchema: WithContext<BreadcrumbList> = {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: [
      { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://yourdomain.com' },
      { '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://yourdomain.com/blog' },
      { '@type': 'ListItem', position: 3, name: post.title, item: `https://yourdomain.com/blog/${post.slug}` },
    ],
  }

  return (
    <>
      <JsonLd schema={blogPostingSchema} />
      <JsonLd schema={breadcrumbSchema} />
      <article>
        <h1>{post.title}</h1>
        {/* rest of your page */}
      </article>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

You can render multiple <JsonLd> components on the same page. Google supports multiple JSON-LD blocks.

FAQPage schema

The FAQPage schema used to be worth extra attention because of the accordion rich result. Google limited that result to well-known government and health sites in 2023 and stopped showing it altogether on May 7, 2026, so don't add FAQPage expecting a SERP feature. It's still valid schema.org markup for pages that literally contain questions and answers, and other consumers of structured data may read it. Don't stuff it onto every page, and keep each answer fully visible in the page HTML.

// Inside a FAQ page component
const faqSchema: WithContext<FAQPage> = {
  '@context': 'https://schema.org',
  '@type': 'FAQPage',
  mainEntity: [
    {
      '@type': 'Question',
      name: 'How long does a headless CMS migration take?',
      acceptedAnswer: {
        '@type': 'Answer',
        text: 'A typical WordPress to headless migration takes 6–12 weeks depending on content complexity and integrations.',
      },
    },
    {
      '@type': 'Question',
      name: 'What is the cost of a Sanity CMS project?',
      acceptedAnswer: {
        '@type': 'Answer',
        text: 'Most mid-size Sanity CMS projects cost between $8,000 and $25,000 depending on schema complexity and custom studio work.',
      },
    },
  ],
}
Enter fullscreen mode Exit fullscreen mode

Person schema for E-E-A-T

Google's E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness) guidelines weight author signals heavily for YMYL and informational content. A Person schema on your author or about page connects your name to a URL that Google can associate with other content you've authored.

Add this to /app/about/page.tsx:

const personSchema: WithContext<Person> = {
  '@context': 'https://schema.org',
  '@type': 'Person',
  name: 'Nayan Kyada',
  url: 'https://yourdomain.com/about',
  jobTitle: 'Next.js + Sanity CMS Developer',
  worksFor: {
    '@type': 'Organization',
    name: 'Freelance / Your Studio Name',
  },
  sameAs: [
    'https://github.com/yourusername',
    'https://twitter.com/yourusername',
    'https://linkedin.com/in/yourusername',
  ],
}
Enter fullscreen mode Exit fullscreen mode

The sameAs array is important — it links your Person entity to authoritative external profiles, which strengthens the knowledge graph connection.

Validating with Rich Results Test

After deploying, test every schema type at search.google.com/test/rich-results. Paste your page URL or raw HTML. The tool shows:

  • Which schema types were detected
  • Whether each type is eligible for a rich result
  • Any errors (required fields missing) or warnings (recommended fields missing)

Common errors to watch for:

Missing datePublished on BlogPosting. Google lists it as recommended rather than required (Article markup has no required properties), but it helps Google show the right date. If your CMS doesn't store a publish date, add one — don't fake it with the build time, which would change on every deploy.

Low-resolution or uncrawlable image. Google accepts either a URL string or an ImageObject, but the image must be crawlable and indexable, and Google recommends high-resolution images.

FAQ answers only in JavaScript. If your FAQ accordion hides text in a JS-only state, crawlers may not see it during indexing. Render the answer text in the HTML, even if it's visually collapsed via CSS.

Breadcrumb item URLs that 404. Every item in a BreadcrumbList should point to a real page. A breadcrumb trail that links to a 404 or a redirect is a signal that the markup doesn't match the site.

Putting JSON-LD in generateMetadata. The Metadata API doesn't support script tags — it only outputs recognised meta fields. JSON-LD added there gets silently ignored. It must be a rendered <script> tag in your JSX.

Keeping schemas in sync with content

The maintenance burden of structured data is low if you generate schemas from the same data object you use to render the page — which is exactly what the code above does. The schema reads from post.title, post.publishedAt, etc., so as long as your CMS data is accurate, the schema stays accurate.

The one thing to watch: absolute URLs. Schema.org properties like url, item, and image.url must be fully qualified (https://yourdomain.com/...), not relative paths. Create a small siteUrl constant and prefix consistently rather than hard-coding the domain in every schema.

Once your schemas are live and passing the Rich Results Test, submit affected URLs to Google Search Console via the URL Inspection tool to request re-indexing. Rich results can appear once Google recrawls the page, but there's no guaranteed timeline and Google doesn't promise to show them.

Structured data won't move you from page 3 to page 1 — that's a content and authority problem. But for pages already in positions 4–15, a rich result can meaningfully lift CTR, and that's a real traffic gain for maybe a day's implementation work.

Top comments (0)