# Nextjs JSON LD: typisiertes Schema, das zur Seite passt

> Nextjs JSON LD im App Router: eine sichere Script-Komponente, typisierte schema-dts-Builder und ein CI-Check, der anschlägt, sobald Markup und Seite abweichen.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 16. Okt. 2026
- Kategorie: [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind)
- Lesezeit: 10 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/nextjs-json-ld
- Englische Fassung: https://wingo-ui.com/blog/nextjs-json-ld

## Kurz gesagt

Im Next.js App Router ist JSON-LD ein einfaches Script-Tag vom Typ application/ld+json, das du aus page.tsx oder layout.tsx renderst: JSON.stringify, dann jedes < durch \u003c ersetzen. Die Metadata API hat dafür kein Feld. Schreib mit schema-dts einen typisierten Builder pro Schema-Typ (SoftwareApplication, BlogPosting, FAQPage, BreadcrumbList) und gib jedem Builder dasselbe Array, das auch die Seite rendert. So kann das Markup nie etwas beschreiben, das Leser nicht sehen.

Du hast einmal strukturierte Daten eingebaut, einen JSON-Block aus einem Generator kopiert, und sechs Monate später behauptet er, die Seite habe sieben FAQ-Fragen, während sie fünf zeigt. Dieses Tutorial richtet nextjs JSON LD so ein, dass das nicht passieren kann: ein typisierter Builder pro Schema-Typ, eine winzige Komponente, die das Script sicher ausgibt, und die Regel, dass jeder Builder dieselben Daten liest, die die Seite rendert. Wir behandeln die vier Typen, die eine Produkt-Website braucht: `SoftwareApplication`, `BlogPosting`, `FAQPage` und `BreadcrumbList`.

Alles hier ist TypeScript für Next.js 16 und React 19. Mehr Guides dieser Art findest du unter [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind).

## Wohin gehört JSON-LD im Next.js App Router?

Direkt in die Page oder das Layout, als einfaches Script-Tag. Der offizielle [JSON-LD-Guide von Next.js](https://nextjs.org/docs/app/guides/json-ld) (zuletzt aktualisiert am 2. März 2026, geprüft am 16. Oktober 2026) empfiehlt, strukturierte Daten als Script-Tag in `layout.js` oder `page.js` zu rendern. Die Next.js Metadata API hilft dabei nicht: `metadata` und `generateMetadata` erzeugen Head-Tags wie Titel, Beschreibung, Canonical und Open Graph. Ein Feld für JSON-LD gibt es dort nicht.

Zwei Details aus demselben Guide sind wichtig:

- `JSON.stringify` bereinigt nichts. Ein Post-Titel mit `</script>` würde das Tag zu früh beenden, also ersetz jedes `<` durch seine Unicode-Escape-Sequenz.
- Verzichte auf `next/script`. Es ist dafür da, JavaScript zu laden und auszuführen. JSON-LD sind Daten, also ist ein natives Script-Tag das richtige Element.

Pack das in eine Komponente und schreib das Tag nie wieder von Hand:

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

// "<" maskiert, damit ein "</script>" in einem Titel oder einer Antwort das Tag nicht beendet
export function JsonLd({ data }: { data: Graph }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
    />
  );
}
```

Sie hat kein `"use client"`, rendert also auf dem Server, und das Script steht in der ersten HTML-Antwort. Dort lesen es auch Crawler und KI-Systeme, die nie JavaScript ausführen.

## Welche Schema-Typen braucht eine Produkt-Website?

Vier decken die meisten Seiten einer SaaS- oder Bibliotheks-Website ab. Was Google mit jedem davon macht, hat sich dieses Jahr geändert. Hier der Stand vom 16. Oktober 2026, laut Google Search Central:

| Typ | Wo | Was Google sagt |
|---|---|---|
| `SoftwareApplication` | Startseite, Preisseite | Ein Rich Result braucht `name`, `offers.price` und entweder `aggregateRating` oder `review` ([Doku](https://developers.google.com/search/docs/appearance/structured-data/software-app)) |
| `BlogPosting` | jeder Post | Keine Pflicht-Properties; empfohlen sind `author`, `datePublished`, `dateModified`, `headline` und `image` ([Doku](https://developers.google.com/search/docs/appearance/structured-data/article)) |
| `FAQPage` | FAQ-Bereiche | Seit September 2023 auf Websites von Behörden und aus dem Gesundheitsbereich beschränkt; das FAQ-Rich-Result erscheint seit dem 7. Mai 2026 nicht mehr, die Doku wurde am 15. Juni 2026 entfernt ([Search Updates](https://developers.google.com/search/updates)) |
| `BreadcrumbList` | jede Seite unterhalb der Startseite | Mindestens zwei `ListItem`s mit `position`, `name` und `item`; das letzte darf `item` weglassen ([Doku](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb)) |

Zwei Anmerkungen zur Tabelle. Erstens verlangt das Rich Result für `SoftwareApplication` Bewertungen oder Rezensionen. Wenn du keine hast, liefer den Knoten ohne sie aus: Er beschreibt trotzdem dein Produkt, seinen Preis und den Herausgeber. Eine Bewertung, die kein Leser auf der Seite sieht, verstößt gegen die Sichtbarkeitsregel, um die es weiter unten geht. Zweitens bringt `FAQPage` in Google nichts Sichtbares mehr. Es ist aber weiter ein gültiger schema.org-Typ, den andere Parser lesen können, und es kostet nichts, wenn es aus Daten kommt, die du sowieso renderst. Genau diese Bedingung gilt für alle vier Typen.

## Wie typisiere ich JSON-LD mit TypeScript in Next.js?

Nutz die Typen von schema-dts und schreib eine kleine Builder-Funktion pro Schema-Typ. Installier die Typen einmal:

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

[schema-dts](https://www.npmjs.com/package/schema-dts) (Version 2.1.0, veröffentlicht am 2. Oktober 2026) ist das schema.org-Vokabular als TypeScript-Typen. Importier es mit `import type`, dann landet nichts im Browser. Danach schreibst du pro Typ einen Builder in einem einfachen Modul. Jeder nimmt die Daten, die deine Seite schon hat, und gibt einen typisierten Knoten zurück. Eine falsch geschriebene Property oder eine Zahl, wo schema.org Text erwartet, lässt dadurch den Typecheck in `next build` scheitern. Diese Datei ist die ganze Schema-Schicht:

```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();

// stabile ids, damit Knoten auf verschiedenen Seiten auf dasselbe zeigen
export const ids = {
  organization: `${ORIGIN}/#organization`,
  software: `${ORIGIN}/#software`,
};

// eine @id plus das Nötigste, damit ein Tool, das nur diese Seite liest, trotzdem sieht, wer und was
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 },
    })),
  };
}

// mehrere Knoten in einem Script
export function graph(...nodes: Thing[]): Graph {
  return { "@context": "https://schema.org", "@graph": nodes };
}
```

Die Verknüpfung läuft über die `@id`-Werte. Der Blogpost verweist über ihre ids darauf, dass er von der Software handelt (`about`) und von der Organisation veröffentlicht wird. So müssen die vollständigen Knoten nur einmal auf der Startseite stehen. Ein reines `{ "@id": ... }` hat aber einen Haken: Ein Tool, das nur den Post liest, sieht einen leeren Knoten, weil es die Startseite nie abruft. Deshalb wiederholen die Refs oben `@type`, `name` und `url` neben der id. Genauso macht es wingo-ui.com für seine Organisations- und Produktknoten. `graph()` packt mehrere Knoten in ein Script, und das liest sich im Validator leichter als vier einzelne Tags.

Der `price`, den du an `softwareApplication()` übergibst, sollte aus derselben Config kommen, die deine Preistabelle rendert. Auf wingo-ui.com entstehen die Offers im JSON-LD aus derselben Plan-Datei, die auch die Preisseite rendert. Ändert sich ein Preis, zieht das Markup automatisch mit.

## Wie halte ich JSON-LD synchron mit der sichtbaren Seite?

Render den sichtbaren Inhalt und das JSON-LD aus demselben Array, im selben Komponentenbaum. Die [Richtlinien für strukturierte Daten](https://developers.google.com/search/docs/appearance/structured-data/sd-policies) von Google (zuletzt aktualisiert am 10. Juli 2026) fassen die Regel in einem Satz: Zeichne keine Inhalte aus, die für Leser der Seite nicht sichtbar sind. Kopiertes JSON verletzt diese Regel schleichend, mit jeder Änderung ein Stück mehr. Mit einem Array und zwei Renderern lesen die sichtbare Komponente und der JSON-LD-Builder denselben Wert, deshalb können sie sich nicht widersprechen.

### Wie baue ich Breadcrumb JSON-LD in Next.js ein?

Bau ein `Crumb[]`-Array und gib es im selben Render an den sichtbaren Breadcrumb-Pfad und an `breadcrumbList()`:

```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))} />
    </>
  );
}
```

Die [Breadcrumb](https://wingo-ui.com/components/breadcrumb) behandelt den letzten Eintrag als aktuelle Seite und rendert ihn als einfachen Text mit `aria-current="page"`. Du kannst sein `href` also für das JSON-LD im Array lassen. Die Smartphone-Ansicht (ein einzelner Zurück-Link zur übergeordneten Seite unter 640px, aus den Defaults `mobileVariant="back"` und `mobileBreakpoint="sm"`) wird per CSS umgeschaltet. Das Server-HTML enthält also immer den vollständigen Pfad, den das JSON-LD beschreibt.

> Live-Demo (Breadcrumb): Mach das Fenster schmaler: Die mittleren Einträge klappen nacheinander in die Ellipse, und unter 640px wird der Pfad zu einem einzigen Zurück-Link. Probier sie unter [Breadcrumb](https://wingo-ui.com/components/breadcrumb) aus und installier sie mit `npx wingo-ui@latest add breadcrumb`.

Über diese Regel sind wir in unserem eigenen Blog gestolpert. Die Vorlage [Blog Post Page](https://wingo-ui.com/templates/blog-post-page) rendert ihren eigenen Pfad (Blog, dann die Kategorie, dann der Post), während die Breadcrumb der Website bei Home beginnt und Kategorien auf ihre eigenen Seiten verlinkt. Zwei Pfade auf einer Seite würden bedeuten, dass das JSON-LD höchstens zu einem davon passt. Deshalb blendet die Website den Pfad der Vorlage mit `classNames={{ breadcrumb: "hidden" }}` aus und rendert eine Server-Breadcrumb, deren Array auch die `BreadcrumbList` füttert.

### Wie baue ich FAQPage JSON-LD in Next.js ein?

Für ein FAQ auf einer Preis- oder Landingpage erledigt die [FAQ Section](https://wingo-ui.com/components/faq-section) beide Aufgaben mit einer Prop. Sie baut auf dem [Accordion](https://wingo-ui.com/components/accordion) auf, die Fragen sind also echte Buttons mit Pfeiltasten-Steuerung. Übergib deine `items` und schalte `jsonLd` ein. Dann gibt sie neben der Liste ein `FAQPage`-Script aus, gebaut aus denselben Items:

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

const faq: FaqItem[] = [
  {
    question: "Kann ich den Plan später wechseln?",
    answer: "Ja. Upgrades gelten sofort, Downgrades ab der nächsten Verlängerung.",
  },
  {
    question: "Wo fange ich an?",
    answer: (
      <>
        Lies den <a href="/docs">Einstiegsguide</a> und installier dann deine erste Komponente.
      </>
    ),
  },
];

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

Wenn eine Antwort JSX ist, sammelt die Section den reinen Text aus ihren Children. Die zweite Antwort wird im Script also zu „Lies den Einstiegsguide und installier dann deine erste Komponente.“ Steht der Text in den Props einer Komponente statt in ihren Children, setz `answerText` auf dieses Item. Die Section maskiert `<` genauso wie die `JsonLd`-Komponente, also kann auch eine Antwort aus einem CMS das Script-Tag nicht schließen.

> Live-Demo (FAQ Section): Öffne eine Frage, und die vorherige schließt sich; mit jsonLd schreiben dieselben Items zusätzlich ein FAQPage-Script ins Server-HTML. Probier sie unter [FAQ Section](https://wingo-ui.com/components/faq-section) aus und installier sie mit `npx wingo-ui@latest add faq-section`.

Nutz `jsonLd`, wenn die Section das einzige FAQ auf der Seite ist. Auf einer Seite, auf der du schon einen `@graph` baust, lass es aus und häng stattdessen `faqPage(items)` an den Graph, aus demselben Array. Zwei `FAQPage`-Knoten für eine Liste sagen dasselbe doppelt und machen die Ausgabe schwerer zu prüfen.

## Wie setze ich alles auf einer Blogpost-Seite zusammen?

Hier ist eine Post-Route, die alles aus einem `post`-Objekt rendert: den Pfad, den Artikel, das FAQ und einen `@graph` mit allen drei Knoten. Das FAQ wird an den Markdown-Body angehängt, also kommen die sichtbaren Fragen und die `FAQPage` aus demselben Array `post.faq`:

```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: "Startseite", href: "/" },
    { name: "Blog", href: "/blog" },
    { name: post.categoryName, href: `/blog/category/${post.category}` },
    { name: post.title, href: path },
  ];
  // das sichtbare FAQ und der FAQPage-Knoten lesen dasselbe 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` ist dein eigener Loader. Hier gibt er die `BlogArticle`-Felder der Vorlage zurück (`slug`, `title`, `excerpt`, `category`, `date`, `readingTime`, `author`, `body`, `tags`) plus `categoryName`, `updated`, `faq` und `related`. `headline` ist `post.title`, also derselbe String, den die Vorlage als h1 rendert, und `dateModified` fällt im Builder auf das Veröffentlichungsdatum zurück.

Ein Stolperstein bei `image`: Der `opengraph-image`-Pfad oben stimmt nur, wenn im Pfad der Route keine Route Group und keine Parallel Route steckt. Unter einem Ordner wie `app/(site)/blog/[slug]` hängt Next.js an die Metadata-Route einen kurzen Hash des übergeordneten Pfads an (`opengraph-image-<hash>`, siehe `getMetadataRouteSuffix` in `next/dist/lib/metadata/get-metadata-route.js`). Ein hartkodierter Pfad zeigt dann auf eine 404-Seite. Kopier die URL aus dem gerenderten `og:image`-Tag oder berechne das Suffix so wie Next.js. Unsere eigene Post-Route liegt unter `app/(library)/(site)/blog/[slug]`, deshalb hat wingo-ui.com einen Helper wie diesen, hier für eine Route unter `app/(site)/blog/[slug]`:

```ts
// djb2 des übergeordneten Ordners der Route, Basis 36, die ersten 6 Zeichen (wie getMetadataRouteSuffix in next)
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]")}`;
```

Übergib den Ordnerpfad genau so, wie er unter `app/` liegt, mit Route Groups, und prüf ihn erneut, wenn du die Route verschiebst.

So ähnlich läuft es auch auf wingo-ui.com. Unsere Post-Seiten packen `BlogPosting` und `FAQPage` in einen Graph und geben die `BreadcrumbList` neben dem sichtbaren Pfad aus. Das `BlogPosting` listet außerdem in `mentions` jede Komponente, die der Post behandelt, jeweils als `SoftwareSourceCode`-Knoten mit der URL ihrer Doku. Wenn du auch Markdown-Versionen für KI-Systeme veröffentlichst, gilt dort dieselbe Idee mit einer einzigen Quelle; siehe [Next.js llms.txt mit .md-Seiten](https://wingo-ui.com/de/blog/nextjs-llms-txt).

## Wie teste ich, ob das Markup zur Seite passt?

Prüf drei Dinge in dieser Reihenfolge: das Server-HTML, die zwei öffentlichen Validatoren und ein eigenes Script, das das Markup mit dem sichtbaren Text vergleicht. Fang mit dem Server-HTML an, denn das liest ein Crawler:

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

Dann füg die URL oder das HTML in den [Schema Markup Validator](https://validator.schema.org/) ein, der jeden schema.org-Typ prüft, und in den [Rich Results Test](https://search.google.com/test/rich-results), der nur die Typen meldet, die Google noch anzeigen kann.

Keines der beiden Tools weiß, was deine Seite anzeigt, also ergänz einen eigenen Check. Dieses Script ruft eine Seite ab, liest jeden JSON-LD-Knoten und schlägt fehl, wenn eine Headline, eine Frage oder der Name eines Breadcrumb-Eintrags im sichtbaren Text fehlt:

```ts
// scripts/check-json-ld.mts
// Aufruf: 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]);

// der sichtbare Text: ohne Scripts, ohne Tags, die Entities, die react schreibt, dekodiert
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(`nicht auf der Seite:\n${missing.join("\n")}`);
  process.exit(1);
}
console.log(`ok: ${nodes.length} Knoten passen zur sichtbaren Seite`);
```

Wir haben es beim Schreiben dieses Posts auf unserem eigenen Dev-Server gegen zwei Posts laufen lassen, und beide meldeten drei passende Knoten (`BlogPosting` und `FAQPage` aus dem Graph, dazu die `BreadcrumbList`). Lass es in der CI gegen ein Preview-Deploy laufen, dann kann der Bug „sieben Fragen im Markup, fünf auf der Seite“ nicht mehr live gehen.

## Was mache ich als Nächstes?

Installier die zwei Komponenten, die sichtbaren Inhalt und sein JSON-LD aus einem Array rendern:

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

Die FAQ Section ist kostenlos. Die Breadcrumb und die Vorlage Blog Post Page gehören zu [Pro](https://wingo-ui.com/de/pricing). Für den Rest des Setups rund um diese Seiten (Tokens, Dark Mode, Kontrast) lies den Pillar-Guide zu [Design Tokens in Tailwind v4](https://wingo-ui.com/de/blog/tailwind-v4-design-tokens). Und wenn du ein Projekt neu aufsetzt, erklärt das [Setup einer Komponentenbibliothek in Next.js](https://wingo-ui.com/de/blog/next-js-component-library-setup) den Teil im App Router.

## Komponenten in diesem Artikel

- [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. Installation: `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. Installation: `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. Installation: `npx wingo-ui@latest add blog-post-page`

## Häufige Fragen

### Wie füge ich JSON-LD im Next.js App Router ein?

Render aus page.tsx oder layout.tsx ein Script-Tag mit type application/ld+json und setz den Inhalt per dangerouslySetInnerHTML: JSON.stringify, dann jedes < durch \u003c ersetzen. Die Metadata API hat kein JSON-LD-Feld, deshalb gehört es nicht in generateMetadata.

### Sollte ich next/script für JSON-LD nutzen?

Nein. Laut dem JSON-LD-Guide von Next.js ist next/script dafür gebaut, JavaScript zu laden und auszuführen, und für strukturierte Daten ist ein natives Script-Tag die richtige Wahl.

### Zeigt Google noch FAQ-Rich-Results?

Nein. Laut Google Search Central erscheint das FAQ-Rich-Result seit dem 7. Mai 2026 nicht mehr in der Google-Suche, und die Doku dazu wurde am 15. Juni 2026 entfernt; seit September 2023 war es nur noch für maßgebliche Websites von Behörden und aus dem Gesundheitsbereich zu sehen. FAQPage ist weiter ein gültiger schema.org-Typ, also behalte ihn nur für Fragen, die auf der Seite sichtbar sind.

### Kann eine Client-Komponente JSON-LD rendern?

Ja, solange das Script Teil der gerenderten Ausgabe ist, denn Client-Komponenten im App Router rendern auch auf dem Server, und das Tag landet im HTML. Füg es nie aus useEffect ein, denn ein Crawler, der kein JavaScript ausführt, würde es nie sehen.

### Brauche ich schema-dts, um JSON-LD in TypeScript zu typisieren?

Nein, aber es hilft. schema-dts (Version 2.1.0, Stand Oktober 2026) liefert das schema.org-Vokabular als TypeScript-Typen; mit import type eingebunden landet nichts im Bundle, und falsch geschriebene Properties oder falsche Werttypen fallen schon beim Build auf.

---

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