DEV Community

Cover image for Next.js SEO: adding JSON-LD structured data with schema.org schemas
Nayan Kyada
Nayan Kyada

Posted on • Originally published at nayankyada.com

Next.js SEO: adding JSON-LD structured data with schema.org schemas

Structured data is one of the few SEO levers that still has a clear, measurable payoff: rich results in Google Search — star ratings, FAQ dropdowns, breadcrumb trails — that 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. Without markup, Google might render it as a plain blue link. With a valid FAQPage schema, it can expand into an accordion directly on the SERP — often doubling the vertical space your result occupies.

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 move CTR from 3% to 6%+ on informational queries.

This guide covers four schemas that cover most content sites:

  • BlogPosting — individual blog or article pages
  • BreadcrumbList — navigation trail shown in the SERP URL
  • FAQPage — Q&A content, surfaces accordion snippets
  • 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 <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"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  )
}
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: { slug: string } }) {
  // Fetch your post data however you do it — Sanity, MDX, etc.
  const post = await getPost(params.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 is worth extra attention because the rich result it produces — a collapsed accordion — is one of the most visible on mobile SERPs. It's best used on pages that literally contain questions and answers, not stuffed onto every page.

The rule Google enforces: each acceptedAnswer must be fully visible on the page. If you hide answers behind a JS-only accordion that Google can't render, the rich result will be rejected.

// 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 I see in the wild:

Missing datePublished on BlogPosting. Google requires it. If your CMS doesn't store a publish date, use your git commit date or Date.now() at build time — anything beats omitting it.

image as a bare string instead of an ImageObject. Both are technically valid schema.org, but Google's validator is stricter. Use an ImageObject with explicit url, width, and height to avoid warnings.

FAQ answers only in JavaScript. If your FAQ accordion hides text in a JS-only state, Googlebot 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 must resolve. If your /blog route returns a 404 or redirects, the breadcrumb enrichment gets dropped.

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 typically appear within days for crawled pages, not weeks.

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)