Type something to search...
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or when it was last edited? Is "Maria" the author or someone mentioned in the text? Structured data removes the guessing. You describe the page's content in a standard vocabulary, and search engines can use it for rich results: star ratings, prices and stock status, breadcrumb trails instead of raw URLs, article dates, and more. AI assistants and other tools that read the web increasingly use the same data.

JSON-LD is the format Google recommends for structured data, and adding it to a Next.js App Router page takes only a few lines. Doing it well takes a bit more care: escaping the output so it can't be used for script injection, keeping it in sync with visible content, typing it so mistakes are caught early, and avoiding duplicates between layouts and pages. This post covers all of that, with ready-to-use examples for the most common page types.

What JSON-LD Looks Like

JSON-LD is a JSON object, embedded in a script tag with type application/ld+json, using the vocabulary from schema.org. A minimal article looks like this:

{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Adding JSON-LD Structured Data to Next.js Pages",
  "datePublished": "2026-09-30T15:45:00Z",
  "author": {
    "@type": "Person",
    "name": "Maria"
  }
}

@context says which vocabulary you're using, @type says what kind of thing this is, and the remaining properties describe it. Browsers ignore the script entirely. It's there for machines.

Rendering JSON-LD in the App Router

The Metadata API doesn't have a JSON-LD field. The recommended approach is to render a regular script element directly in your page.tsx or layout.tsx:

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";

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

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.publishedAt,
  };

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
        }}
      />
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  );
}

Three details here are deliberate.

A native script, not next/script. The Script component from next/script is for loading and executing JavaScript with a loading strategy. JSON-LD isn't executable, and you want it in the server-rendered HTML exactly as written, so a plain script element is correct.

It's rendered in a Server Component. The JSON-LD ends up in the initial HTML, where crawlers read it, and none of it ships as client JavaScript.

The < replacement. This is the important one. JSON.stringify doesn't escape HTML. If any value contains the string </script> (say, a post title or a user-submitted review), it closes your script tag early, and whatever follows is parsed as HTML. That's a script injection vulnerability. Replacing every < with its Unicode escape < keeps the JSON valid (JSON parsers decode it back to <) while making it impossible to close the tag. Do this every time, even for data you think is safe.

A Reusable, Typed Component

Repeating that script block and escape on every page invites mistakes. Wrap it in a small component, and add types with the schema-dts package so typos in property names are caught by TypeScript:

npm install -D schema-dts
// components/json-ld.tsx
import type { Graph, Thing, WithContext } from "schema-dts";

type Props = {
  data: WithContext<Thing> | Graph;
};

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

schema-dts provides TypeScript types for the full schema.org vocabulary. WithContext<BlogPosting> requires @context and @type and only allows properties that exist on BlogPosting and its parent types. Graph is for multiple linked entities, which we'll get to shortly. It's a dev dependency because it only contains types.

Now a post page looks like this:

// app/blog/[slug]/page.tsx
import type { BlogPosting, WithContext } from "schema-dts";
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/json-ld";
import { getPost } from "@/lib/posts";
import { BASE_URL } from "@/lib/site";

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

  const url = `${BASE_URL}/blog/${post.slug}`;

  const jsonLd: WithContext<BlogPosting> = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.excerpt,
    image: [`${BASE_URL}${post.coverImage}`],
    datePublished: post.publishedAt,
    dateModified: post.updatedAt ?? post.publishedAt,
    author: {
      "@type": "Person",
      name: post.author.name,
      url: `${BASE_URL}/authors/${post.author.slug}`,
    },
    publisher: { "@id": `${BASE_URL}/#organization` },
    mainEntityOfPage: url,
  };

  return (
    <article>
      <JsonLd data={jsonLd} />
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  );
}

A few notes on the properties:

  • headline should match the visible title. Keep it reasonably short; very long headlines may be truncated in results.
  • image should be absolute URLs of images that actually appear on or represent the page. Google recommends providing high-resolution images, ideally in multiple aspect ratios if you have them.
  • Dates should be ISO 8601 with a timezone, like 2026-09-30T15:45:00Z. A date without time is allowed, but including the timezone avoids your post appearing a day off.
  • author with a url pointing at an author page helps search engines connect articles by the same person.
  • publisher references an organization defined elsewhere by its @id. More on that next.

Site-Wide Data: Organization and WebSite

Some entities describe the whole site rather than one page: your organization (name, logo, social profiles) and the website itself. Put these in the root layout so they're on every page, and give each one an @id so pages can reference them instead of repeating them.

// app/layout.tsx
import type { ReactNode } from "react";
import type { Graph } from "schema-dts";
import { JsonLd } from "@/components/json-ld";
import { BASE_URL } from "@/lib/site";

const siteJsonLd: Graph = {
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": `${BASE_URL}/#organization`,
      name: "TideWave",
      url: BASE_URL,
      logo: `${BASE_URL}/images/logo.png`,
      sameAs: [
        "https://github.com/tidewave",
        "https://www.linkedin.com/company/tidewave",
      ],
    },
    {
      "@type": "WebSite",
      "@id": `${BASE_URL}/#website`,
      name: "TideWave",
      url: BASE_URL,
      publisher: { "@id": `${BASE_URL}/#organization` },
    },
  ],
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <JsonLd data={siteJsonLd} />
        {children}
      </body>
    </html>
  );
}

@graph holds several entities in one block. Each has an @id, a URL-shaped identifier that doesn't need to resolve to a real page; it just has to be unique and stable. The BlogPosting above uses publisher: { "@id": ".../#organization" } to point at this organization. Search engines combine all JSON-LD blocks on a page, so references work across separate script tags.

The Organization data with a logo and sameAs links helps search engines associate your brand with its logo and official profiles.

Breadcrumbs

Breadcrumb structured data can replace the raw URL under your result with a readable trail like "TideWave > Blog > Next.js". It's one of the most broadly useful types, and easy to generate from the route.

// lib/breadcrumbs.ts
import type { BreadcrumbList, WithContext } from "schema-dts";
import { BASE_URL } from "@/lib/site";

export function breadcrumbJsonLd(
  items: { name: string; path: string }[],
): WithContext<BreadcrumbList> {
  return {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: items.map((item, index) => ({
      "@type": "ListItem",
      position: index + 1,
      name: item.name,
      item: `${BASE_URL}${item.path}`,
    })),
  };
}
// in app/blog/[slug]/page.tsx, alongside the BlogPosting
<JsonLd
  data={breadcrumbJsonLd([
    { name: "Home", path: "/" },
    { name: "Blog", path: "/blog" },
    { name: post.title, path: `/blog/${post.slug}` },
  ])}
/>

Positions start at 1. The breadcrumb should reflect a real navigational hierarchy on your site, ideally matching a visible breadcrumb component on the page.

Products, Offers, and Reviews

For e-commerce pages, Product structured data can show price, availability, and ratings directly in results, which tends to have the biggest impact on click-through of any type.

// app/products/[slug]/page.tsx
import type { Product, WithContext } from "schema-dts";
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/json-ld";
import { getProduct } from "@/lib/products";
import { BASE_URL } from "@/lib/site";

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

  const jsonLd: WithContext<Product> = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    description: product.description,
    image: product.images.map((src) => `${BASE_URL}${src}`),
    sku: product.sku,
    brand: { "@type": "Brand", name: product.brand },
    offers: {
      "@type": "Offer",
      url: `${BASE_URL}/products/${product.slug}`,
      price: product.price.toFixed(2),
      priceCurrency: "USD",
      availability: product.inStock
        ? "https://schema.org/InStock"
        : "https://schema.org/OutOfStock",
    },
    ...(product.reviewCount > 0 && {
      aggregateRating: {
        "@type": "AggregateRating",
        ratingValue: product.rating,
        reviewCount: product.reviewCount,
      },
    }),
  };

  return (
    <main>
      <JsonLd data={jsonLd} />
      <h1>{product.name}</h1>
      {/* price, stock status, reviews... */}
    </main>
  );
}

Points that commonly cause validation warnings or ineligibility:

  • price and priceCurrency must match what the page shows. Use a string or number without currency symbols.
  • availability uses full schema.org URLs like https://schema.org/InStock.
  • aggregateRating must reflect real reviews visible on the page. Including it only when there are reviews (the conditional spread above) avoids publishing a rating of zero from zero reviews.

Choosing Types Worth Adding

Schema.org has hundreds of types, but only some lead to rich results in Google. Focus on types that match your content and are actually used:

PageUseful types
Every pageOrganization, WebSite (root layout)
Blog post or news articleBlogPosting or Article, BreadcrumbList
Product pageProduct with Offer, AggregateRating, Review
Event pageEvent with location, startDate, offers
Job listingJobPosting
RecipeRecipe
Software or app pageSoftwareApplication
Local businessLocalBusiness (or a subtype) with address and hours

A couple of types are worth a caveat. Google narrowed FAQPage rich results to a small set of authoritative government and health sites in 2023, and stopped showing HowTo rich results. The markup is still valid and other consumers may read it, but don't expect visible changes in Google results from adding it to a typical site.

Check Google's search gallery documentation for the current list of supported features and their required properties. Requirements change over time, and the documentation is the source of truth.

Keeping Structured Data Honest

Search engines treat structured data as a claim about the page, and they check it against what users see. A few rules keep you on the right side of their guidelines:

  • Describe what's visible. Don't mark up reviews, prices, or FAQs that aren't on the page.
  • Generate it from the same data as the page. Building JSON-LD from the same post or product object that renders the HTML guarantees they match. Hand-maintained JSON-LD drifts.
  • Don't mark up hidden or irrelevant content. A rating for your company on every product page, for example, isn't a rating for that product.

Violations can lead to a manual action that removes rich results for your site, so this isn't just pedantry.

Avoiding Duplicates Between Layouts and Pages

Because layouts wrap pages, it's easy to end up with two blocks describing the same thing, for example a WebPage in a layout and an Article in the page with conflicting names. Some guidelines:

  • Put site-wide entities (Organization, WebSite) in the root layout, once.
  • Put page-specific entities (BlogPosting, Product, BreadcrumbList) in the page.tsx that has the data.
  • Link them by @id rather than repeating the organization inside every article.

Multiple script tags of type application/ld+json on one page are perfectly fine. Duplicated or contradictory entities are what cause confusion.

Testing and Validation

Before shipping, check the output in three places:

  1. View source on a production build and search for application/ld+json. Confirm the JSON is in the server-rendered HTML and the values look right.
  2. Rich Results Test at https://search.google.com/test/rich-results shows which rich result types Google detects on a URL or code snippet, plus errors and warnings for missing properties.
  3. Schema Markup Validator at https://validator.schema.org/ validates against the full schema.org vocabulary, which is useful for types Google doesn't use for rich results.

After deployment, Google Search Console shows enhancement reports for detected types (breadcrumbs, products, and others), with counts of valid items and errors across your whole site. That's where you'll notice if a template change breaks structured data on hundreds of pages at once.

For local testing of a preview URL that isn't publicly reachable, paste the rendered HTML into the Rich Results Test's code tab.

FAQ

Can I put JSON-LD in the head? It works in either the head or the body. Rendering it in your page or layout component, as shown here, places it in the body, which search engines read without any issue.

Will this work with Client Components? It would render, but there's no benefit. Keep JSON-LD in Server Components so it's in the initial HTML and adds nothing to your JavaScript bundle.

Does structured data improve rankings directly? It's not a direct ranking factor. It makes your results eligible for richer presentation, which can improve click-through. For the broader picture, see the SEO benefits of Next.js.

Conclusion

JSON-LD in Next.js is a script element with type application/ld+json, rendered from a Server Component. Wrap it in a small component that always escapes < as <, type the data with schema-dts, and build it from the same objects that render the page. Put Organization and WebSite in the root layout with stable @id values, add BlogPosting, Product, and BreadcrumbList to the pages they describe, and validate with the Rich Results Test before you ship. Combined with good metadata from the Metadata API, that gives search engines everything they need to present your pages well.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading
Adding Syntax Highlighting to Code Blocks in a Next.js Blog

Adding Syntax Highlighting to Code Blocks in a Next.js Blog

If you write about code, your code blocks are half the post. Plain monospace text in a gray box works, but readers scan code by color: keywords, stri

Continue Reading