# Nextjs JSON LD: Typed Schema That Matches the Page

> Nextjs JSON LD for the App Router: a safe script component, typed schema-dts builders and a CI check that fails when the markup drifts from the page.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/nextjs-json-ld

## TL;DR

In the Next.js App Router, JSON-LD is a plain script tag of type application/ld+json that you render from page.tsx or layout.tsx, with JSON.stringify and every < replaced by \u003c; the Metadata API has no field for it. Write one typed builder per schema type (SoftwareApplication, BlogPosting, FAQPage, BreadcrumbList) with schema-dts, and feed each builder the same array the page renders, so the markup can never describe something the reader cannot see.

You added structured data once, copied a JSON blob from a generator, and six months later it says the page has seven FAQ questions while the page shows five. This tutorial sets up nextjs JSON LD so that drift cannot happen: one typed builder per schema type, a tiny component that prints the script safely, and a rule that every builder reads the same data the page renders. We cover the four types a product site needs: `SoftwareApplication`, `BlogPosting`, `FAQPage` and `BreadcrumbList`.

Everything here is TypeScript for Next.js 16 and React 19. More guides like this one are in [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind).

## Where does JSON-LD go in the Next.js App Router?

In the page or layout itself, as a plain script tag. The official [Next.js JSON-LD guide](https://nextjs.org/docs/app/guides/json-ld) (last updated March 2, 2026, checked October 16, 2026) recommends rendering structured data as a script tag in `layout.js` or `page.js`. The Next.js Metadata API does not help here: `metadata` and `generateMetadata` produce head tags such as title, description, canonical and Open Graph, and there is no JSON-LD field.

Two details from the same guide matter:

- `JSON.stringify` does not sanitize anything. A post title that contains `</script>` would end the tag early, so replace every `<` with its unicode escape.
- Do not use `next/script`. It exists to load and run JavaScript, and JSON-LD is data, so a native script tag is the right element.

Put that in one component and never write the tag by hand again:

```tsx
// components/json-ld.tsx
import type { Graph } from "schema-dts";

// "<" escaped so a "</script>" inside a title or answer cannot end the tag
export function JsonLd({ data }: { data: Graph }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
    />
  );
}
```

It has no `"use client"`, so it renders on the server and the script is in the first HTML response, where crawlers and AI readers that never run JavaScript can read it.

## Which schema types does a product site need?

Four cover most pages of a SaaS or library site. What Google does with each one changed this year, so here is the state as of October 16, 2026, from Google Search Central:

| Type | Where | What Google says |
|---|---|---|
| `SoftwareApplication` | home, pricing | A rich result needs `name`, `offers.price` and either `aggregateRating` or `review` ([docs](https://developers.google.com/search/docs/appearance/structured-data/software-app)) |
| `BlogPosting` | each post | No required properties; `author`, `datePublished`, `dateModified`, `headline` and `image` are recommended ([docs](https://developers.google.com/search/docs/appearance/structured-data/article)) |
| `FAQPage` | FAQ sections | Limited to government and health sites since September 2023; the FAQ rich result stopped appearing on May 7, 2026 and its docs were removed on June 15, 2026 ([search updates](https://developers.google.com/search/updates)) |
| `BreadcrumbList` | every page below the root | At least two `ListItem`s with `position`, `name` and `item`; the last item may skip `item` ([docs](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb)) |

Two notes on that table. First, the `SoftwareApplication` rich result requires ratings or reviews. If you have none, ship the node without them: it still describes your product, its price and its publisher. A rating that no reader can see on the page breaks the visibility policy covered below. Second, `FAQPage` no longer earns anything visible in Google. It is still a valid schema.org type that other parsers can read, and it costs nothing when it comes from data you already render. That is the condition for all four types.

## How do I type JSON-LD with TypeScript in Next.js?

Use the schema-dts types and write one small builder function per schema type. Install the types once:

```bash
npm install -D schema-dts
```

[schema-dts](https://www.npmjs.com/package/schema-dts) (version 2.1.0, published October 2, 2026) is the schema.org vocabulary as TypeScript types. Import it with `import type` and nothing ships to the browser. Then write one builder per type in a plain module. Each takes the data your page already has and returns a typed node, so a misspelled property or a number where schema.org wants text fails the type check in `next build`. This file is the whole schema layer:

```ts
// lib/json-ld.ts
import type { BlogPosting, BreadcrumbList, FAQPage, Graph, SoftwareApplication, Thing } from "schema-dts";

const ORIGIN = "https://example.com";
const SITE_NAME = "Example";

// "/blog/post" -> "https://example.com/blog/post"
export const absoluteUrl = (path: string) => new URL(path, ORIGIN).toString();

// stable ids, so nodes on different pages point at the same thing
export const ids = {
  organization: `${ORIGIN}/#organization`,
  software: `${ORIGIN}/#software`,
};

// an @id plus the basics, so a tool that reads only this page still sees who and what
const organizationRef = () => ({ "@type": "Organization", "@id": ids.organization, name: SITE_NAME, url: absoluteUrl("/") }) as const;
const softwareRef = () => ({ "@type": "SoftwareApplication", "@id": ids.software, name: SITE_NAME, url: absoluteUrl("/") }) as const;

export type Crumb = { name: string; href: string };
export type FaqEntry = { question: string; answer: string };

export function softwareApplication(input: { name: string; description: string; price: number; currency: string }): SoftwareApplication {
  return {
    "@type": "SoftwareApplication",
    "@id": ids.software,
    name: input.name,
    description: input.description,
    url: absoluteUrl("/"),
    applicationCategory: "DeveloperApplication",
    operatingSystem: "Any",
    offers: { "@type": "Offer", price: input.price, priceCurrency: input.currency, url: absoluteUrl("/pricing") },
    publisher: organizationRef(),
  };
}

export function blogPosting(input: {
  title: string;
  description: string;
  path: string;
  datePublished: string;
  dateModified?: string;
  image: string;
  author: { name: string; path: string };
  keywords: string[];
}): BlogPosting {
  const url = absoluteUrl(input.path);
  return {
    "@type": "BlogPosting",
    "@id": `${url}#article`,
    headline: input.title,
    description: input.description,
    url,
    mainEntityOfPage: url,
    image: absoluteUrl(input.image),
    datePublished: input.datePublished,
    dateModified: input.dateModified ?? input.datePublished,
    author: { "@type": "Person", name: input.author.name, url: absoluteUrl(input.author.path) },
    publisher: organizationRef(),
    about: softwareRef(),
    keywords: input.keywords.join(", "),
  };
}

export function breadcrumbList(crumbs: Crumb[]): BreadcrumbList {
  return {
    "@type": "BreadcrumbList",
    itemListElement: crumbs.map((crumb, i) => ({
      "@type": "ListItem",
      position: i + 1,
      name: crumb.name,
      item: absoluteUrl(crumb.href),
    })),
  };
}

export function faqPage(entries: FaqEntry[]): FAQPage {
  return {
    "@type": "FAQPage",
    mainEntity: entries.map((entry) => ({
      "@type": "Question",
      name: entry.question,
      acceptedAnswer: { "@type": "Answer", text: entry.answer },
    })),
  };
}

// several nodes in one script
export function graph(...nodes: Thing[]): Graph {
  return { "@context": "https://schema.org", "@graph": nodes };
}
```

The `@id` values do the linking. The blog post says it is `about` the software and published by the organization by pointing at their ids, so the full nodes can live once on the home page. A bare `{ "@id": ... }` has a catch: a tool that reads only the post sees an empty node, because it never fetches the home page. That is why the refs above repeat `@type`, `name` and `url` next to the id, the same thing wingo-ui.com does for its organization and product nodes. `graph()` puts several nodes in one script, which is easier to read in the validator than four separate tags.

The `price` you pass to `softwareApplication()` should come from the same config your pricing table renders. On wingo-ui.com the offers in the JSON-LD are generated from the plans file that also draws the pricing page, so a price change cannot leave the markup behind.

## How do I keep JSON-LD in sync with the visible page?

Render the visible content and the JSON-LD from the same array, in the same component tree. Google's [structured data policies](https://developers.google.com/search/docs/appearance/structured-data/sd-policies) (last updated July 10, 2026) put the rule in one line: do not mark up content that is not visible to readers of the page. Copy-pasted JSON breaks that rule slowly, one edit at a time. With one array and two renderers, the visible component and the JSON-LD builder read the same value, so they cannot disagree.

### How do I add breadcrumb JSON-LD in Next.js?

Build one `Crumb[]` array and pass it to the visible trail and to `breadcrumbList()` in the same render:

```tsx
import { Breadcrumb } from "@/components/ui/breadcrumb";
import { JsonLd } from "@/components/json-ld";
import { breadcrumbList, graph, type Crumb } from "@/lib/json-ld";

export function PageTrail({ crumbs }: { crumbs: Crumb[] }) {
  return (
    <>
      <Breadcrumb items={crumbs.map((crumb) => ({ label: crumb.name, href: crumb.href }))} />
      <JsonLd data={graph(breadcrumbList(crumbs))} />
    </>
  );
}
```

The [Breadcrumb](https://wingo-ui.com/components/breadcrumb) treats the last entry as the current page and renders it as plain text with `aria-current="page"`, so you can keep its `href` in the array for the JSON-LD. Its phone form (one back link to the parent page below 640px, from the defaults `mobileVariant="back"` and `mobileBreakpoint="sm"`) is switched with CSS, so the server HTML always holds the full trail that the JSON-LD describes.

> Live demo (Breadcrumb): Narrow the window: the middle crumbs fold into the ellipsis one by one, and below 640px the trail turns into a single back link. Try it at [Breadcrumb](https://wingo-ui.com/components/breadcrumb) and install it with `npx wingo-ui@latest add breadcrumb`.

We hit this rule on our own blog. The [Blog Post Page](https://wingo-ui.com/templates/blog-post-page) template draws its own trail (Blog, then the category, then the post), while the site's breadcrumb starts at Home and links categories to their own pages. Two trails on one page would mean the JSON-LD matches at most one of them. So the site hides the template's trail with `classNames={{ breadcrumb: "hidden" }}` and renders one server breadcrumb whose array also feeds the `BreadcrumbList`.

### How do I add FAQPage JSON-LD in Next.js?

For a FAQ on a pricing or landing page, the [FAQ Section](https://wingo-ui.com/components/faq-section) does both jobs from one prop. It is built on the [Accordion](https://wingo-ui.com/components/accordion), so the questions are real buttons with arrow key support. Pass your `items` and turn on `jsonLd`, and it prints a `FAQPage` script next to the list, built from the same items:

```tsx
// app/pricing/page.tsx
import { FaqSection, type FaqItem } from "@/components/blocks/faq-section";

const faq: FaqItem[] = [
  {
    question: "Can I switch plans later?",
    answer: "Yes. Upgrades apply right away and downgrades apply at the next renewal.",
  },
  {
    question: "Where do I start?",
    answer: (
      <>
        Read the <a href="/docs">getting started guide</a>, then install your first component.
      </>
    ),
  },
];

export default function PricingPage() {
  return <FaqSection items={faq} jsonLd />;
}
```

When an answer is JSX, the section walks its children for the plain text, so the second answer becomes "Read the getting started guide, then install your first component." in the script. If the text lives in a component's props instead of its children, set `answerText` on that item. The section escapes `<` the same way the `JsonLd` component does, so an answer from a CMS cannot close the script tag either.

> Live demo (FAQ Section): Open a question and the previous one closes; with jsonLd on, these same items also print a FAQPage script into the server HTML. Try it at [FAQ Section](https://wingo-ui.com/components/faq-section) and install it with `npx wingo-ui@latest add faq-section`.

Use `jsonLd` when the section is the only FAQ on the page. On a page where you already build a `@graph`, leave it off and add `faqPage(items)` to the graph instead, from the same array. Two `FAQPage` nodes for one list say the same thing twice and make the output harder to check.

## How do I put it all together on a blog post page?

Here is a post route that renders everything from one `post` object: the trail, the article, the FAQ and one `@graph` with all three nodes. The FAQ is appended to the Markdown body, so the visible questions and the `FAQPage` come from the same `post.faq` array:

```tsx
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { BlogPostPage } from "@/components/templates/blog-post-page";
import { Breadcrumb } from "@/components/ui/breadcrumb";
import { JsonLd } from "@/components/json-ld";
import { blogPosting, breadcrumbList, faqPage, graph, type Crumb } from "@/lib/json-ld";
import { getPost, getPosts } from "@/lib/posts";

export const dynamicParams = false;

export function generateStaticParams() {
  return getPosts().map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const post = getPost((await params).slug);
  if (!post) return {};
  return { title: post.title, description: post.excerpt, alternates: { canonical: `/blog/${post.slug}` } };
}

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

  const path = `/blog/${post.slug}`;
  const crumbs: Crumb[] = [
    { name: "Home", href: "/" },
    { name: "Blog", href: "/blog" },
    { name: post.categoryName, href: `/blog/category/${post.category}` },
    { name: post.title, href: path },
  ];
  // the visible faq and the FAQPage node read the same array
  const faqMarkdown = post.faq.map((entry) => `### ${entry.question}\n\n${entry.answer}`).join("\n\n");

  return (
    <>
      <JsonLd
        data={graph(
          blogPosting({
            title: post.title,
            description: post.excerpt,
            path,
            datePublished: post.date,
            dateModified: post.updated,
            image: `${path}/opengraph-image`,
            author: { name: post.author.name, path: post.author.href ?? "/about" },
            keywords: post.tags ?? [],
          }),
          breadcrumbList(crumbs),
          faqPage(post.faq),
        )}
      />
      <Breadcrumb
        items={crumbs.map((crumb) => ({ label: crumb.name, href: crumb.href }))}
        className="mx-auto w-full max-w-[1200px] px-4 pt-6 sm:px-6"
      />
      <BlogPostPage
        article={{ ...post, body: `${post.body}\n\n## FAQ\n\n${faqMarkdown}` }}
        categories={[{ value: post.category, label: post.categoryName }]}
        relatedPosts={post.related}
        header={false}
        footer={false}
        classNames={{ breadcrumb: "hidden" }}
      />
    </>
  );
}
```

`getPost` is your own loader. Here it returns the template's `BlogArticle` fields (`slug`, `title`, `excerpt`, `category`, `date`, `readingTime`, `author`, `body`, `tags`) plus `categoryName`, `updated`, `faq` and `related`. `headline` is `post.title`, the same string the template renders as the h1, and `dateModified` falls back to the publish date inside the builder.

One pitfall with `image`: the `opengraph-image` path above is right only when the route has no route group or parallel route in its path. Under a folder like `app/(site)/blog/[slug]`, Next.js appends a short hash of the parent path to the metadata route (`opengraph-image-<hash>`, see `getMetadataRouteSuffix` in `next/dist/lib/metadata/get-metadata-route.js`). A hardcoded path then points at a 404. Copy the URL from the rendered `og:image` tag, or compute the suffix the way Next.js does. Our own post route sits under `app/(library)/(site)/blog/[slug]`, so wingo-ui.com carries a helper like this one, shown here for a route at `app/(site)/blog/[slug]`:

```ts
// djb2 of the route's parent folder, base 36, first 6 characters (same as next's getMetadataRouteSuffix)
function metadataRouteSuffix(parent: string) {
  let hash = 5381;
  for (let i = 0; i < parent.length; i++) hash = ((hash << 5) + hash + parent.charCodeAt(i)) & 0xffffffff;
  return (hash >>> 0).toString(36).slice(0, 6);
}

const ogImage = (slug: string) => `/blog/${slug}/opengraph-image-${metadataRouteSuffix("/(site)/blog/[slug]")}`;
```

Pass the folder path exactly as it sits under `app/`, route groups included, and recheck it when you move the route.

This is close to what wingo-ui.com runs. Our post pages put `BlogPosting` and `FAQPage` in one graph and emit the `BreadcrumbList` beside the visible trail. The `BlogPosting` also lists every component the post covers in `mentions`, each as a `SoftwareSourceCode` node with its docs URL. If you also publish Markdown twins for AI readers, the same single-source idea applies there; see [Next.js llms.txt with .md pages](https://wingo-ui.com/blog/nextjs-llms-txt).

## How do I test that the markup matches the page?

Check three things in order: the server HTML, the two public validators, and a script of your own that compares the markup with the visible text. Start with the server HTML, since that is what a crawler reads:

```bash
curl -s http://localhost:3000/blog/my-post | grep -o '<script type="application/ld+json">[^<]*'
```

Then paste the URL or the HTML into the [Schema Markup Validator](https://validator.schema.org/), which checks any schema.org type, and the [Rich Results Test](https://search.google.com/test/rich-results), which only reports the types Google can still show.

Neither tool knows what your page displays, so add a check of your own. This script fetches a page, reads every JSON-LD node and fails when a headline, question or crumb name is missing from the visible text:

```ts
// scripts/check-json-ld.mts
// usage: npx tsx scripts/check-json-ld.mts http://localhost:3000/blog/my-post
type Node = { "@type"?: string; headline?: string; mainEntity?: { name: string }[]; itemListElement?: { name: string }[] };

const html = await (await fetch(process.argv[2])).text();
const nodes: Node[] = [...html.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)]
  .map((match) => JSON.parse(match[1]))
  .flatMap((doc) => doc["@graph"] ?? [doc]);

// the visible text: no scripts, no tags, the entities react writes decoded
const visible = html
  .replace(/<script[\s\S]*?<\/script>/g, " ")
  .replace(/<[^>]+>/g, " ")
  .replace(/&#x27;/g, "'")
  .replace(/&quot;/g, '"')
  .replace(/&lt;/g, "<")
  .replace(/&gt;/g, ">")
  .replace(/&amp;/g, "&")
  .replace(/\s+/g, " ");

const missing: string[] = [];
for (const node of nodes) {
  const names =
    node["@type"] === "BlogPosting" ? [node.headline] :
    node["@type"] === "FAQPage" ? node.mainEntity?.map((q) => q.name) :
    node["@type"] === "BreadcrumbList" ? node.itemListElement?.map((item) => item.name) : [];
  for (const name of names ?? []) if (name && !visible.includes(name)) missing.push(`${node["@type"]}: ${name}`);
}

if (missing.length > 0) {
  console.error(`not on the page:\n${missing.join("\n")}`);
  process.exit(1);
}
console.log(`ok: ${nodes.length} nodes match the visible page`);
```

We ran it against two posts on our own dev server while editing this one, and each reported three nodes matching (the graph's `BlogPosting` and `FAQPage`, plus the `BreadcrumbList`). Run it in CI against a preview deploy and the "seven questions in the markup, five on the page" bug cannot ship.

## What should I do next?

Install the two components that render visible content and its JSON-LD from one array:

```bash
npx wingo-ui@latest add faq-section breadcrumb
```

The FAQ Section is free. The Breadcrumb and the Blog Post Page template are part of [Pro](https://wingo-ui.com/pricing). For the rest of the setup these pages sit in (tokens, dark mode, contrast), read the pillar guide on [Tailwind v4 design tokens](https://wingo-ui.com/blog/tailwind-v4-design-tokens), and if you are starting a project from scratch, the [Next.js component library setup](https://wingo-ui.com/blog/next-js-component-library-setup) walks through the App Router side.

## Components in this post

- [FAQ Section](https://wingo-ui.com/components/faq-section) (Free): A React FAQ section for marketing pages: an accordion, a split layout, an open grid or expandable cards, with topics and live search. Install: `npx wingo-ui@latest add faq-section`
- [Breadcrumb](https://wingo-ui.com/components/breadcrumb) (Pro): A React breadcrumb that shows where you are, folds the middle of long paths and gives phones a back link. Install: `npx wingo-ui@latest add breadcrumb`
- [Blog Post Page](https://wingo-ui.com/templates/blog-post-page) (Pro): A React blog post template: byline, share actions, a drawn cover, a Markdown body with a sticky table of contents and reading progress. Install: `npx wingo-ui@latest add blog-post-page`

## FAQ

### How do I add JSON-LD in the Next.js App Router?

Render a script tag with type application/ld+json from a page.tsx or layout.tsx and set its content with dangerouslySetInnerHTML, using JSON.stringify with every < replaced by \u003c. The Metadata API has no JSON-LD field, so it does not go in generateMetadata.

### Should I use next/script for JSON-LD?

No. The Next.js JSON-LD guide says next/script is built for loading and running JavaScript, and that a native script tag is the right choice for structured data.

### Does Google still show FAQ rich results?

No. Google Search Central says the FAQ rich result stopped appearing in Google Search on May 7, 2026, and removed its documentation on June 15, 2026; since September 2023 it had only shown for authoritative government and health sites. FAQPage is still a valid schema.org type, so keep it only for questions that are visible on the page.

### Can a client component render JSON-LD?

Yes, as long as the script is part of the rendered output, because App Router client components also render on the server and the tag lands in the HTML. Never inject it from useEffect, since a crawler that does not run JavaScript would never see it.

### Do I need schema-dts to type JSON-LD in TypeScript?

It is optional but useful. schema-dts (version 2.1.0 as of October 2026) gives you the schema.org vocabulary as TypeScript types; imported with import type, it adds nothing to the bundle and catches misspelled properties and wrong value types at build time.

---

Source: https://wingo-ui.com/blog/nextjs-json-ld
