All posts
structured-data
json-ld
nextjs

JSON-LD Structured Data in Next.js: A Practical Guide

How to add JSON-LD structured data to Next.js pages. Organization, Article, Product, FAQ, BreadcrumbList, and SoftwareApplication schemas with TypeScript.

March 16, 20268 min

You refactored your Next.js Layout component on Friday. By Tuesday, your organic click-through rate dropped 12%. The culprit: you accidentally stripped the <script type="application/ld+json"> tag containing your Product schema, instantly killing the review stars and pricing rich results on 45 product pages. SEO relies on structured data, but without type safety and CI validation, it's just a fragile string waiting to break.

JSON-LD (JavaScript Object Notation for Linked Data) is the standard format Google uses to understand the context of your pages. When implemented correctly, it transforms standard search links into rich results with FAQs, pricing, breadcrumbs, and review stars. When implemented poorly, Google silently ignores it.

How do you add JSON-LD to a Next.js App Router page?

Inject a <script> tag inside your page.tsx or layout.tsx returning a stringified JSON object, or use a strongly-typed helper like Indxel's generateLD() to enforce schema compliance at compile time.

Next.js 14 handles standard metadata (titles, descriptions, Open Graph) via the metadata export, but it does not provide native abstractions for JSON-LD. You must render the script tag manually.

The native, untyped approach looks like this:

// app/blog/[slug]/page.tsx
export default function BlogPost({ params }) {
  const post = getPost(params.slug);
 
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    datePublished: post.publishedAt,
    author: [{
      '@type': 'Person',
      name: post.authorName,
    }],
  };
 
  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <h1>{post.title}</h1>
      {/* Post content */}
    </section>
  );
}

This approach fails at scale. If you misspell datePublished as publishedDate, TypeScript compiles perfectly. Your page renders perfectly. But Google's parser rejects the schema, and you lose your rich snippet.

The Indxel approach enforces schema.org and Google Search Central requirements at the type level:

// app/blog/[slug]/page.tsx
import { generateLD } from '@indxel/core';
 
export default function BlogPost({ params }) {
  const post = getPost(params.slug);
 
  // TypeScript throws an error if required fields like 'headline' 
  // or 'author' are missing based on the 'Article' schema.
  const schema = generateLD('Article', {
    headline: post.title,
    datePublished: post.publishedAt,
    author: {
      type: 'Person',
      name: post.authorName,
    },
  });
 
  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
      />
      <h1>{post.title}</h1>
    </section>
  );
}

Always use dangerouslySetInnerHTML for JSON-LD in React. Passing a raw string as children to the <script> tag causes React hydration warnings because the browser formats the JSON string differently than the server output.

Which JSON-LD schemas do you need for a SaaS application?

A standard SaaS application requires Organization schema on the homepage, SoftwareApplication and Product on the pricing page, Article on the blog, and FAQPage on support sections.

Shipping random schemas to every route dilutes your entity structure. Map specific schemas to specific page intents.

Page RouteRequired SchemaGoogle Rich ResultMeasurable Impact
/ (Homepage)OrganizationKnowledge PanelControls company logo and social links in branded search.
/pricingProduct / SoftwareApplicationProduct SnippetDisplays price, currency, and aggregate rating directly in SERP.
/blog/[slug]ArticleTop StoriesRequired for inclusion in mobile news carousels.
/docs/*BreadcrumbListBreadcrumbsReplaces raw URL paths with readable, clickable navigation hierarchies.
/faqFAQPageFAQ AccordionOccupies up to 3x more vertical pixel space on mobile SERPs.

How do you implement Organization schema on the homepage?

Place the Organization schema in your root layout.tsx or homepage page.tsx to define your company logo, canonical URL, and social profiles for Google's Knowledge Panel.

You only need to output Organization schema once on your primary entry point. Emitting it on all 400 pages of your site is redundant and increases payload size unnecessarily.

// app/page.tsx
import { generateLD } from '@indxel/core';
 
export default function Home() {
  const orgSchema = generateLD('Organization', {
    name: 'Indxel',
    url: 'https://indxel.com',
    logo: 'https://indxel.com/logo.png',
    sameAs: [
      'https://twitter.com/indxel',
      'https://github.com/indxel'
    ],
    contactPoint: {
      type: 'ContactPoint',
      telephone: '+1-800-555-0199',
      contactType: 'customer service',
      areaServed: 'US',
      availableLanguage: 'en'
    }
  });
 
  return (
    <main>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }}
      />
      <h1>Developer-first SEO infrastructure</h1>
    </main>
  );
}

Google requires the logo URL to be crawlable and indexable. SVG files are supported, but PNG/JPG guarantees compatibility across all parsers.

How do you add SoftwareApplication and Product schemas?

Bind SoftwareApplication and Product schemas to your landing and pricing pages to display price, currency, and aggregate ratings directly in search results.

For SaaS companies, SoftwareApplication defines the platform, while Product (via the offers property) defines the subscription tiers. Google's rich result parser strictly requires price, priceCurrency, and a valid AggregateRating to show the review stars snippet.

// app/pricing/page.tsx
import { generateLD } from '@indxel/core';
 
export default function PricingPage() {
  const softwareSchema = generateLD('SoftwareApplication', {
    name: 'Indxel Pro',
    applicationCategory: 'DeveloperApplication',
    operatingSystem: 'Web',
    aggregateRating: {
      type: 'AggregateRating',
      ratingValue: '4.9',
      ratingCount: '1024'
    },
    offers: {
      type: 'Offer',
      price: '29.00',
      priceCurrency: 'USD',
      priceValidUntil: '2025-12-31',
      availability: 'https://schema.org/InStock'
    }
  });
 
  return (
    <main>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareSchema) }}
      />
      <h1>Pricing</h1>
      {/* Pricing cards */}
    </main>
  );
}

Hardcoding aggregateRating can trigger a manual penalty from Google if the reviews are fake or not visible on the page. Ensure ratingValue and ratingCount match actual user data rendered in the DOM.

How do you structure Article schema for a Next.js blog?

Generate Article schema dynamically inside your blog [slug]/page.tsx route using the post's frontmatter to secure the "Top Stories" carousel placement.

Google differentiates between Article, NewsArticle, and BlogPosting. For standard developer blogs, Article or BlogPosting are functionally identical. The critical fields are headline, image (must be at least 1200px wide), datePublished, and author.

// app/blog/[slug]/page.tsx
import { generateLD } from '@indxel/core';
import { notFound } from 'next/navigation';
 
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetchPost(params.slug);
  if (!post) notFound();
 
  const articleSchema = generateLD('Article', {
    headline: post.title,
    description: post.excerpt,
    image: [post.coverImage],
    datePublished: new Date(post.createdAt).toISOString(),
    dateModified: new Date(post.updatedAt).toISOString(),
    author: {
      type: 'Person',
      name: post.author.name,
      url: `https://indxel.com/team/${post.author.slug}`
    }
  });
 
  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
      />
      <h1>{post.title}</h1>
    </article>
  );
}

Notice the toISOString() conversion. Google's parser explicitly requires ISO 8601 format for dates. Passing a raw UNIX timestamp or a formatted string like MM/DD/YYYY results in a validation failure.

How do you build FAQPage and BreadcrumbList schemas?

Map your FAQ data arrays directly into the FAQPage schema format, and construct BreadcrumbList from your Next.js router path segments to get expandable search snippets.

FAQ schemas dominate mobile search results by pushing competitors down the page. However, you must map over your exact data source.

// components/faq-section.tsx
import { generateLD } from '@indxel/core';
 
const faqs = [
  { q: "Does Indxel support App Router?", a: "Yes, fully supported." },
  { q: "Is there a CI/CD integration?", a: "Yes, via GitHub Actions." }
];
 
export function FaqSection() {
  const faqSchema = generateLD('FAQPage', {
    mainEntity: faqs.map(faq => ({
      type: 'Question',
      name: faq.q,
      acceptedAnswer: {
        type: 'Answer',
        text: faq.a
      }
    }))
  });
 
  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
      />
      <h2>Frequently Asked Questions</h2>
      {faqs.map((faq, i) => (
        <details key={i}>
          <summary>{faq.q}</summary>
          <p>{faq.a}</p>
        </details>
      ))}
    </section>
  );
}

For BreadcrumbList, calculate the position dynamically:

// components/breadcrumbs.tsx
import { generateLD } from '@indxel/core';
 
export function Breadcrumbs({ segments }: { segments: { name: string, url: string }[] }) {
  const breadcrumbSchema = generateLD('BreadcrumbList', {
    itemListElement: segments.map((seg, index) => ({
      type: 'ListItem',
      position: index + 1,
      name: seg.name,
      item: seg.url
    }))
  });
 
  return (
    <nav>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
      />
      {/* Render visual breadcrumbs */}
    </nav>
  );
}

How do you validate JSON-LD before shipping to production?

Run npx indxel check in your CI/CD pipeline to validate JSON-LD syntax, schema compliance, and missing required properties before merging pull requests.

Manual validation relies on pasting production URLs into the Google Rich Results Test. This is reactive. If the test fails, the broken code is already live, and Googlebot might have already crawled it.

Automated validation blocks the deployment. The Indxel CLI parses your built Next.js HTML, extracts all application/ld+json payloads, and runs them against 15 strict rules covering canonical URL resolution, missing priceCurrency fields, and ISO 8601 date formatting.

Add this step to your GitHub Actions workflow:

# .github/workflows/seo-check.yml
name: SEO Validation
on: [pull_request]
 
jobs:
  validate-json-ld:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build
      
      # Validates the static output in .next/server/app
      - name: Indxel Schema Check
        run: npx indxel check --ci --diff origin/main

The CLI outputs warnings in the same format as ESLint — one line per issue, with file path and rule ID:

$ npx indxel check --ci
Analyzing .next build output...
 
/pricing
✖ error  Missing required property 'priceCurrency' in Product schema  [schema-product-currency]
⚠ warn   'priceValidUntil' is in the past (2023-12-31)                [schema-offer-expired]
 
/blog/nextjs-14-seo
✔ pass   Article schema validated (8 properties)
 
Score: 98/100
1 critical error, 1 warning.
Build failed. Run with --force to bypass.

Why automate structured data validation?

A typical Next.js app with 150 static pages and 5 dynamic routes takes 4.2 seconds to validate locally or in CI. That 4.2 seconds prevents regressions that cost revenue.

Consider an e-commerce or SaaS site with a 5% conversion rate. You ship a redesign that accidentally nests the price property under the wrong JSON object. TypeScript doesn't catch it because you used any or bypassed type checks on the schema object. Google crawls the page, drops your product rich snippet, and your organic CTR drops from 4.1% to 2.8%.

By the time you notice the drop in Google Search Console, diagnose the missing JSON-LD field, push a fix, and wait for Google to recrawl the 45 affected pages, 14 days have passed. You lost 14 days of optimized traffic.

Automated validation catches the structural error at the PR level. The build fails, the developer fixes the nesting, and the regression never reaches production.

FAQ

Should I use JSON-LD or Microdata in Next.js?

Use JSON-LD exclusively. Google officially recommends JSON-LD over Microdata and RDFa because it decouples the structured data from your HTML markup. Microdata requires you to add itemprop attributes directly to your React components, which bloats the DOM, complicates component reusability, and makes type validation nearly impossible.

Can I render JSON-LD in a Client Component?

Yes, but you shouldn't. While Googlebot executes JavaScript and can parse JSON-LD injected client-side via useEffect, relying on client-side rendering adds unnecessary risk and delays indexing. Always render your <script type="application/ld+json"> tags in Server Components (page.tsx or layout.tsx without the "use client" directive) so the schema is present in the initial HTML payload.

Does Google guarantee rich results if my JSON-LD is valid?

No. Valid JSON-LD makes you eligible for rich results, but Google's algorithm decides whether to display them based on page quality, domain authority, and search intent. However, invalid JSON-LD guarantees you will never get a rich result.


Stop shipping broken schemas and waiting for Google Search Console to email you about it. Add strict typing to your Next.js application today.

npm install @indxel/core
npx indxel init