Back to Blog
Guides11 min read21 July 2026

How to Add Schema Markup to a Next.js Site

Add JSON-LD structured data to Next.js App Router and Pages Router: server components, dynamic data, TypeScript types, and the pitfalls to avoid.

By AI Schema Gen Team

Most structured data guides assume WordPress. Install a plugin, fill in some fields, done. On Next.js there's no plugin; you're emitting JSON-LD yourself, and the framework introduces genuine complications: server versus client components, when scripts execute relative to crawling, hydration, and how to generate markup from CMS data at build time.

None of it is difficult once you know the patterns. But the failure modes are quiet: markup that validates locally and never reaches a crawler, or duplicate blocks that contradict each other. It's worth getting right the first time.

This covers App Router and Pages Router, dynamic generation from a CMS, TypeScript typing, and the mistakes that cost people rich results.

Where JSON-LD goes in Next.js

The short version: render a <script type="application/ld+json"> tag directly in your component tree, in a server component, using dangerouslySetInnerHTML.

That's Next.js's own documented recommendation, and it's simpler than what most people reach for. You don't need a third-party SEO package, and you don't need to inject anything into <head>. JSON-LD is valid anywhere in the document, and Google explicitly supports it in the body.

The minimal App Router version:

// app/products/[slug]/page.tsx

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const product = await getProduct(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.image,
    sku: product.sku,
    offers: {
      '@type': 'Offer',
      price: product.price,
      priceCurrency: 'USD',
      availability: product.inStock
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
    },
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <h1>{product.name}</h1>
      {/* rest of the page */}
    </>
  )
}

Because this is a server component, the script tag is present in the initial HTML response. That's the critical property: a crawler fetching the page sees the markup without executing any JavaScript.

Escape the JSON, or you have an XSS hole

dangerouslySetInnerHTML is named that way for a reason, and this is the part most tutorials skip.

If any value in your JSON-LD comes from user input, a CMS, or product data you don't fully control, a string containing </script> will break out of the script tag and inject arbitrary HTML into your page. JSON.stringify does not escape this for you.

The fix is small:

function safeJsonLd(data: object): string {
  return JSON.stringify(data).replace(/</g, '\\u003c')
}

Then:

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>

Escaping the < character as its Unicode equivalent is still valid JSON and parses identically, but it can no longer terminate the script element. If you take one thing from this article, take this: a product description containing a stray closing script tag is not hypothetical on a site pulling from a CMS with rich-text fields.

Wrap it in a reusable component and the problem disappears permanently:

// components/JsonLd.tsx

export function JsonLd({ data }: { data: object | object[] }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(data).replace(/</g, '\\u003c'),
      }}
    />
  )
}

Sitewide schema in the root layout

Organization and WebSite markup describe the site rather than any individual page, so they belong in app/layout.tsx where they render on every route:

// app/layout.tsx
import { JsonLd } from '@/components/JsonLd'

const organization = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  '@id': 'https://example.com/#organization',
  name: 'Example Ltd',
  url: 'https://example.com',
  logo: 'https://example.com/logo.png',
  sameAs: [
    'https://www.linkedin.com/company/example',
    'https://github.com/example',
  ],
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <JsonLd data={organization} />
        {children}
      </body>
    </html>
  )
}

Note the @id. That stable identifier lets page-level markup reference this same organization instead of describing a second, duplicate one.

Dynamic schema from a CMS

The real advantage of doing this in Next.js is that markup generates from the same data that renders the page, so it can't drift out of sync the way hand-maintained markup does.

For statically generated routes this happens at build time:

// app/blog/[slug]/page.tsx
import { JsonLd } from '@/components/JsonLd'

export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    '@id': `https://example.com/blog/${post.slug}#article`,
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt ?? post.publishedAt,
    author: {
      '@type': 'Person',
      '@id': `https://example.com/authors/${post.author.slug}#person`,
      name: post.author.name,
      url: `https://example.com/authors/${post.author.slug}`,
    },
    publisher: { '@id': 'https://example.com/#organization' },
    mainEntityOfPage: `https://example.com/blog/${post.slug}`,
  }

  return (
    <>
      <JsonLd data={jsonLd} />
      <article>{/* ... */}</article>
    </>
  )
}

Two things worth noticing. Dates come straight from the CMS, so dateModified updates automatically on every edit, one of the most commonly stale properties on hand-maintained sites. And publisher is a bare @id reference pointing at the Organization defined in the layout, rather than a duplicated object.

Connecting entities with @graph

Once a page carries several related objects (an article, its author, a breadcrumb trail, the publishing organization), the clean way to express them is a single @graph array with cross-references by @id:

const jsonLd = {
  '@context': 'https://schema.org',
  '@graph': [
    {
      '@type': 'BlogPosting',
      '@id': 'https://example.com/blog/post#article',
      headline: post.title,
      author: { '@id': 'https://example.com/authors/jane#person' },
      publisher: { '@id': 'https://example.com/#organization' },
    },
    {
      '@type': 'Person',
      '@id': 'https://example.com/authors/jane#person',
      name: 'Jane Doe',
      url: 'https://example.com/authors/jane',
      sameAs: ['https://www.linkedin.com/in/janedoe'],
    },
    {
      '@type': 'BreadcrumbList',
      '@id': 'https://example.com/blog/post#breadcrumb',
      itemListElement: [
        {
          '@type': 'ListItem',
          position: 1,
          name: 'Home',
          item: 'https://example.com/',
        },
        {
          '@type': 'ListItem',
          position: 2,
          name: 'Blog',
          item: 'https://example.com/blog',
        },
      ],
    },
  ],
}

This describes one connected graph of entities rather than several unrelated blobs. It's how the same author, referenced from fifty articles, is understood as one person rather than fifty separate people, the foundation of the entity signals that matter for machine comprehension and AI search.

TypeScript: catch mistakes at compile time

Hand-written JSON-LD is stringly-typed and easy to get subtly wrong: a misspelled property, a value where an object belongs. Google maintains schema-dts, which provides TypeScript definitions for the whole schema.org vocabulary:

npm install --save-dev schema-dts
import type { Product, WithContext } from 'schema-dts'

const jsonLd: WithContext<Product> = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  name: product.name,
  offers: {
    '@type': 'Offer',
    price: product.price,
    priceCurrency: 'USD',
  },
}

Now a misspelled property is a compile error rather than a silent omission, and your editor autocompletes valid properties. For a site with more than a handful of schema types, this pays for itself quickly.

Pages Router

On the older Pages Router, the same JSON-LD goes inside next/head:

// pages/products/[slug].tsx
import Head from 'next/head'

export default function ProductPage({ product }) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
  }

  return (
    <>
      <Head>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
          }}
        />
      </Head>
      {/* page content */}
    </>
  )
}

Use getStaticProps or getServerSideProps to fetch the data. The same rule applies: markup must be in the server-rendered HTML, not added on the client.

Rendering modes and caching

Next.js gives you several rendering strategies, and each has implications for structured data that are easy to overlook.

Static generation (SSG). Markup is baked into the HTML at build time. This is the strongest option for crawlers: the JSON-LD is unconditionally present in the response with no runtime work. The trade-off is staleness: if a product price changes in your CMS but you don't rebuild, your markup advertises the old price. That's not merely inaccurate, it's the kind of mismatch between markup and visible content that Google's guidelines prohibit.

Incremental Static Regeneration (ISR). Pages regenerate in the background on a revalidation interval, which keeps markup reasonably fresh without rebuilding the whole site. For catalogues with changing prices or stock, set the revalidation window to something shorter than your tolerance for a stale price appearing in a rich result. A day-long window on a store running flash sales will produce mismatches.

Server-side rendering (SSR). Markup is generated per request, so it's always current. Use it where accuracy genuinely matters more than cacheability: live inventory, real-time pricing.

Partial prerendering and streaming. If you're streaming content with Suspense boundaries, keep JSON-LD in the static shell rather than inside a suspended boundary. Markup that arrives in a later chunk is less reliably picked up than markup present in the initial response.

The general principle: whatever renders the visible price, availability, or date should render the markup, in the same pass, from the same data. Every architecture that separates those two things eventually produces a mismatch.

Internationalized sites

If you run locale-based routes, structured data needs to reflect the locale rather than defaulting to one version everywhere.

Set inLanguage on content types to the correct BCP 47 tag for the route. Localize the values themselves, not just the wrapper: a French route with an English description sends a confused signal. Currency in Offer should match what the page actually displays, which for multi-currency stores means reading it from the same source as the rendered price rather than hardcoding USD.

Keep @id values locale-specific where the entity genuinely differs per locale, and shared where it doesn't. Your Organization is one organization across every locale and should keep a single canonical @id; a localized article is a distinct page and should have its own. Pair this with proper hreflang annotations, which do the work of relating locale variants to each other; structured data doesn't replace them.

The mistakes that cost rich results

Emitting JSON-LD from a client component or useEffect. The most damaging error and the hardest to notice, because it works perfectly when you inspect the DOM in your browser. Markup injected after hydration may not be reliably seen by crawlers. Keep JSON-LD in server components. If a component genuinely must be client-side, lift the schema up to a server parent.

Duplicate schema from layout and page. Define Organization once in the root layout and reference it by @id elsewhere. Two full Organization objects on one page describe two organizations.

Unescaped JSON. Covered above. It's a security bug, not a formatting nit.

Markup that doesn't match visible content. Google's guidelines require structured data to reflect what's actually on the page. Easy to violate when markup is generated from a CMS field that isn't rendered: an internal summary field used for description while the page displays something else. This is the one genuine manual-action risk.

Non-ISO 8601 dates and durations. datePublished needs a full ISO timestamp, not a locale-formatted string. Durations need PT30M, not "30 minutes." If your CMS returns Date objects, call .toISOString().

Stale dateModified. If it's hardcoded or falls back to datePublished forever, you lose the freshness signal. Wire it to a real CMS timestamp.

Claiming rich results that no longer exist. Google has retired eleven structured data features since 2023, including HowTo in 2023 and FAQPage in May 2026. The markup stays valid and unpenalized, but don't build a feature around a SERP treatment that isn't there. Our guide to what Google still supports has the current list.

Testing and validation

Validate the rendered output, not your source. Run a production build and serve it, then view source: actual view-source, not the devtools element inspector, which shows the post-hydration DOM. If the JSON-LD isn't in view-source, a crawler may not see it.

Google's Rich Results Test checks eligibility for supported rich result types. It only reports on types Google still supports; for a retired type it returns nothing, which is not an error.

The Schema Markup Validator at validator.schema.org performs generic schema.org validation independent of Google's features. Use this for types that don't produce rich results.

Search Console shows how Google actually parsed your markup sitewide after crawling, surfacing issues that spot-checking individual URLs won't.

Test with JavaScript disabled as a quick sanity check that markup is genuinely server-rendered.

Which types should a Next.js site implement?

Next.js powers a fairly predictable set of site archetypes, and the right markup differs meaningfully between them. All of the below are types Google currently supports.

SaaS or product marketing site. Organization in the root layout with thorough sameAs links to your company profiles, SoftwareApplication on the product page with category, pricing, and rating, BreadcrumbList on nested routes, and Article or BlogPosting across your content. If you publish pricing, keep the Offer values synchronized with your pricing page; a mismatch here is both a policy problem and an obvious credibility one.

Headless commerce. Product on every product route with Offer and, where you have real reviews, AggregateRating. Model variants with ProductGroup and hasVariant rather than collapsing them. Add merchant-oriented markup (shipping and return policy) if you want the fuller merchant listing treatment rather than a basic product snippet. BreadcrumbList on category paths.

Documentation or developer content. Article or TechArticle with real author entities, BreadcrumbList reflecting your docs hierarchy, and VideoObject where you embed demos. Documentation sites tend to have unusually clean hierarchies, so breadcrumb markup is cheap and worth doing.

Publisher or media site. Article or NewsArticle with Person author entities carrying sameAs links, Organization as publisher, accurate datePublished and dateModified from the CMS, and VideoObject for embedded media. If you have a comments system with substantive user answers, Q&A or DiscussionForumPosting may apply; both are supported, and both are frequently missed.

Marketplace or directory. Organization for the site, plus the type matching what you list: Product, LocalBusiness, Event, or JobPosting. Listing pages generally shouldn't carry a single item's markup; put the item markup on the item's own route.

For anything less common, check Google's structured data gallery before building; the supported list has narrowed four times since 2023, and implementing a retired type expecting a visual result is wasted work. Our schema types directory covers implementation detail per type.

A note on scale

Everything above works well for a site with a handful of templates. It gets harder as you grow: keeping types current as Google's supported list changes, handling less common types correctly, validating at scale, and keeping markup in sync as content evolves.

That's the gap our MCP connector closes for headless and Next.js sites: schema generated from your content and kept current, without hand-maintaining JSON-LD across dozens of templates. If you'd rather hand-roll it, everything in this guide is what you need. If you'd rather not, that's what we build.

Frequently Asked Questions


AI Schema Gen's MCP connector generates and validates structured data for Next.js and headless sites across 827+ schema types. Start free or read the documentation.

Generate perfect schema in 30 seconds

AI Schema Gen handles everything automatically, free to start.

Get Started Free