Next.js Schema Markup: Server-Side JSON-LD (App Router Guide)

Last Updated: February 25, 2026 · 13 min read

Next.js is the leading React framework for SEO-conscious sites — and it has an excellent JSON-LD story. The App Router renders schema server-side by default, meaning Google sees your structured data in the initial HTML without any JavaScript execution. This guide covers every implementation pattern from sitewide schema in layout.tsx to dynamic route product schemas.

✅ Why Next.js is ideal for schema markup

  • • Server Components render JSON-LD in the initial HTML response
  • • No "JavaScript SEO" rendering delay — Googlebot sees schema immediately
  • dangerouslySetInnerHTML is safe in Server Components (no XSS risk from server-rendered content)

Pattern 1: Sitewide Schema in layout.tsx

For schema that applies to every page (Organization, WebSite, SearchAction), add it to the root layout. This renders once in the <head> on every page:

// app/layout.tsx
import type { Metadata } from 'next';

const organizationSchema = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  '@id': 'https://yoursite.com/#organization',
  'name': 'Your Company Name',
  'url': 'https://yoursite.com',
  'logo': {
    '@type': 'ImageObject',
    'url': 'https://yoursite.com/logo.png',
    'width': 512,
    'height': 512,
  },
  'sameAs': [
    'https://twitter.com/yourhandle',
    'https://linkedin.com/company/yourcompany',
  ],
};

const websiteSchema = {
  '@context': 'https://schema.org',
  '@type': 'WebSite',
  '@id': 'https://yoursite.com/#website',
  'name': 'Your Site Name',
  'url': 'https://yoursite.com',
  'publisher': { '@id': 'https://yoursite.com/#organization' },
  'potentialAction': {
    '@type': 'SearchAction',
    'target': {
      '@type': 'EntryPoint',
      'urlTemplate': 'https://yoursite.com/search?q={search_term_string}',
    },
    'query-input': 'required name=search_term_string',
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
        />
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema) }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Pattern 2: Page-Level Schema in page.tsx

For page-specific schema (Article, Product, FAQPage), add the JSON-LD block directly in the page component. It renders into the page HTML, not the head — which is fine for structured data:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

// Schema is defined outside or inside the component
const articleSchema = {
  '@context': 'https://schema.org',
  '@type': 'Article',
  'headline': 'Static Page Title',
  'datePublished': '2026-02-25T09:00:00Z',
  'author': {
    '@type': 'Person',
    'name': 'Jane Doe',
    'url': 'https://yoursite.com/authors/jane',
  },
};

export default function BlogPost() {
  return (
    <>
      {/* Place before or after your markup — Google finds it anywhere */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
      />
      <article>
        <h1>My Blog Post</h1>
        ...
      </article>
    </>
  );
}

Pattern 3: Dynamic Schema from Route Params

For dynamic routes (e.g. product pages, blog posts from a CMS), generate schema from the fetched data:

// app/products/[slug]/page.tsx
import { getProduct } from '@/lib/api';

export default async function ProductPage({
  params,
}: {
  params: { slug: string };
}) {
  const product = await getProduct(params.slug);

  const productSchema = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    'name': product.name,
    'image': product.imageUrl,
    'description': product.description,
    'sku': product.sku,
    'brand': {
      '@type': 'Brand',
      'name': product.brand,
    },
    'offers': {
      '@type': 'Offer',
      'price': product.price.toString(),
      'priceCurrency': 'USD',
      'availability': product.inStock
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
      'url': `https://yoursite.com/products/${params.slug}`,
    },
    ...(product.rating && {
      'aggregateRating': {
        '@type': 'AggregateRating',
        'ratingValue': product.rating.average.toFixed(1),
        'reviewCount': product.rating.count,
        'bestRating': '5',
      },
    }),
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }}
      />
      <main>
        <h1>{product.name}</h1>
        ...
      </main>
    </>
  );
}

Pattern 4: Schema Utility Function

For teams managing many schema types, extract generation into typed utility functions:

// lib/schema.ts
export interface ArticleSchemaInput {
  headline: string;
  datePublished: string;
  dateModified: string;
  authorName: string;
  authorUrl: string;
  description: string;
  imageUrl: string;
  url: string;
}

export function articleSchema(input: ArticleSchemaInput) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Article',
    'headline': input.headline.slice(0, 110), // max 110 chars
    'datePublished': input.datePublished,
    'dateModified': input.dateModified,
    'author': {
      '@type': 'Person',
      'name': input.authorName,
      'url': input.authorUrl,
    },
    'publisher': {
      '@id': 'https://yoursite.com/#organization',
    },
    'description': input.description,
    'image': input.imageUrl,
    'mainEntityOfPage': {
      '@type': 'WebPage',
      '@id': input.url,
    },
  };
}

// Usage in page.tsx:
// import { articleSchema } from '@/lib/schema';
// const schema = articleSchema({ headline: post.title, ... });

Important: dangerouslySetInnerHTML vs next/script

ApproachRenders SSR?Recommended for schema?
dangerouslySetInnerHTML in Server Component✅ Yes (inline in HTML)✅ Best choice
next/script strategy="beforeInteractive"✅ Yes✅ Also works
next/script strategy="afterInteractive"⚠️ Client-side only❌ Avoid for schema
next/script strategy="lazyOnload"⚠️ Client-side only❌ Avoid for schema
useEffect to inject script❌ Client-side only❌ Never use for schema

Frequently Asked Questions

Does Next.js Server Components render JSON-LD on the server?

Yes. In the App Router, all Server Components (the default) render on the server. Any <script> tag with dangerouslySetInnerHTML inside a Server Component is included in the initial HTML response, meaning Google sees your JSON-LD without executing JavaScript.

Is dangerouslySetInnerHTML safe for schema markup in Next.js?

Yes, in Server Components. The XSS risk with dangerouslySetInnerHTML only applies to client-rendered content where untrusted user input could be injected. For schema, the JSON is generated server-side from your own data, making it safe.

Should I put schema markup in the <head> or the <body>?

Either location is fine for Google. JSON-LD inside <head> is traditional; placing it in the page <body> (as a sibling to <article> or <main>) is equally valid by the spec and standard Next.js practice. Google processes both.

Can I use next/script for schema markup?

Use next/script with strategy="beforeInteractive" or avoid it for schema. The default strategy ("afterInteractive") is client-side only, which means Google may miss it. The safest and simplest approach is dangerouslySetInnerHTML in a Server Component, not next/script.

How do I add schema markup to dynamic Next.js routes?

In dynamic route pages (app/products/[slug]/page.tsx), fetch your data server-side then build the schema object from the fetched data before returning JSX. The schema is specific to each page's content and rendered server-side for each request (or at build time for static routes).

What is the best way to reuse schema code across pages?

Create typed utility functions in lib/schema.ts that accept page data and return a schema object. Import these functions in each page component. This avoids repetition, ensures consistency, and makes it easy to update schema properties in one place.

Will React hydration affect my schema markup?

No. Schema markup rendered by Server Components is static HTML — React hydration only applies to interactive client-side elements. Your JSON-LD blocks are inert <script> tags that React does not need to hydrate.

How do I verify that schema is rendered server-side in Next.js?

Right-click your page → View Page Source (not DevTools Elements). Search for "application/ld+json" in the raw HTML. If you can find your schema in the raw source, it is server-rendered. If it only appears in DevTools Elements, it is client-side only.

Test Your Next.js Schema

Paste your JSON-LD or enter your live URL to validate server-side rendering and rich result eligibility.

Validate Schema →