Skip to main content
JSON-LD in the Next.js App Router: How We Type, Serialize, and Test Structured Data — Field Notes

JSON-LD in the Next.js App Router: How We Type, Serialize, and Test Structured Data — Field Notes

October 17, 2026
Frontend Engineering
9 min read
Headline: In the Next.js App Router, JSON-LD belongs in a plain script tag of type application/ld+json rendered by a Server Component, serialized with JSON.stringify(data).replace(/</g, '\\u003c') so page content can never close the tag, and typed with the schema-dts package. In 2026 we ship Person or Organization, WebSite, BlogPosting and BreadcrumbList, and we no longer add FAQPage or HowTo for rich results because Google stopped showing them for most sites in 2023.

The first time we added structured data to Next.js App Router projects, our team did it the quick way: a JSON.stringify inside a script tag, in whichever component happened to have the data. Three problems surfaced over time. A content field containing </script> could break out of the tag. The dates in the JSON-LD drifted from the dates on the page. And several schema types we were shipping no longer produce anything in Google Search. These are our notes from cleaning that up, and the setup we now ship on every content site at Devya.

Key takeaways

• JSON-LD (JSON for Linking Data) is a JSON block that describes a page with the schema.org vocabulary; browsers do not execute it, and Google recommends it over Microdata and RDFa.

• The Next.js Metadata API has no JSON-LD field, so render a <script type="application/ld+json"> from the Server Component that owns the data.

• JSON.stringify does not escape <, so replace every < with \u003c before injecting, or a </script> in content closes the tag early.

• schema-dts gives compile-time types for every schema.org type but does not enforce Google's recommended properties; a test against the rendered HTML does that.

• FAQPage and HowTo no longer earn rich results for most sites; Person, Organization, WebSite, BlogPosting and BreadcrumbList are still worth shipping.

What does JSON-LD actually do for a Next.js site in 2026?

JSON-LD does two separate jobs: it makes a page eligible for specific Google rich results, and it states unambiguously who wrote the page, when, and which entity the page is about. Google has said structured data is not a general ranking factor, so neither job is a ranking boost on its own.

The rich-result job has been shrinking. Since August 2023 Google shows FAQ rich results only for well-known, authoritative government and health sites, and HowTo rich results were removed the same year. In 2025 Google retired another batch of lesser-used structured data features. The disambiguation job has not shrunk: a BlogPosting with datePublished, dateModified and an author linked to a Person with sameAs profile URLs is still the clearest machine-readable answer to "who said this, and when?"

For AI answer engines the evidence is thinner. Microsoft's Bing team has said publicly that schema markup helps its LLMs understand content; OpenAI, Perplexity and Anthropic have not documented how their crawlers use JSON-LD. What we can control is delivery: many AI crawlers fetch raw HTML without running JavaScript, so JSON-LD has to be in the server-rendered HTML, not injected after hydration.

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

JSON-LD goes in the Server Component that already has the data, usually page.tsx, as a plain <script type="application/ld+json"> element. The Next.js Metadata API (metadata and generateMetadata) has no JSON-LD field, and the Next.js docs recommend rendering the script tag from a layout or page instead.

We use one small component on every page. The component is a Server Component with no 'use client' directive. It takes a schema-dts Graph or WithContext<Thing> as its data prop and returns a script element of type application/ld+json whose dangerouslySetInnerHTML is JSON.stringify(data).replace(/</g, '\\u003c').

Two details matter. The component has no 'use client', so it ships zero client JavaScript and the JSON lands in the initial HTML. It is a plain <script>, not next/script: next/script exists to control how executable scripts load, and a data block has nothing to load.

How do we serialize JSON-LD without opening an XSS hole?

Escape every < as \u003c after JSON.stringify. JSON.stringify escapes quotes and control characters but leaves <, > and / untouched, and the HTML parser ends a script element at the first </script it sees, regardless of JSON string quotes.

That turns any untrusted string into a breakout. If a CMS title, author display name or comment excerpt contains </script><script>alert(1)</script>, the browser closes the JSON-LD block early and runs the next tag as JavaScript. \u003c is a valid JSON escape that parses back to <, so the data stays identical while </script and <!-- become impossible to form. The Next.js JSON-LD guide uses the same .replace(/</g, '\\u003c'). dangerouslySetInnerHTML is the documented way to set the script body; the replace call is what makes it safe.

How do we type schema.org objects in TypeScript?

The schema-dts package, published from Google's GitHub organization, provides TypeScript types for the whole schema.org vocabulary. WithContext<T> adds the @context key to a single node, and Graph types a document with an @graph array. The types are compile-time only, so they add nothing to the bundle.

We build the whole page graph in one module and link nodes by @id, a URL-shaped identifier that lets one node reference another without repeating it. The module defines a Person node with an @id ending in /#person, a name, a url and sameAs links to GitHub and LinkedIn, plus a WebSite node with its own @id whose publisher points at the person's @id. A postGraph(post) function builds a BlogPosting whose @id is the post URL plus #article, with headline, description, image, datePublished and dateModified from toISOString(), author and publisher as { '@id': PERSON_ID } references, mainEntityOfPage set to the post URL, and isPartOf pointing at the WebSite. It returns a document whose @context is schema.org and whose @graph holds the person, website and article nodes.

We emit one @graph per page that repeats the site-level Person and WebSite nodes, instead of a site-wide block in the root layout plus a separate page block. Whether a consumer merges @id references across separate script tags is not something we want to depend on.

schema-dts catches typos like datePublised and wrong value shapes. It does not enforce what Google recommends: almost every property is optional in the schema.org vocabulary, and Google's Article documentation lists no required properties at all, only recommended ones such as headline, image, datePublished, dateModified and author. The types keep the JSON well-formed; a test keeps it complete.

Which schema.org types are still worth shipping in 2026?

Entity and article types are worth shipping on every content site; FAQPage and HowTo are not worth adding for rich results. This is the table our team uses:

• Person / Organization — every page, inside @graph. Entity understanding: author, logo, sameAs profiles; no snippet of its own. Our call: ship.

• WebSite — every page, inside @graph. Site-name display in results, read from the homepage markup. Our call: ship.

• BlogPosting / Article — post pages. Better title, image and date understanding; no dedicated card. Our call: ship.

• BreadcrumbList — nested pages. Breadcrumb trail in desktop results; dropped on mobile in January 2025. Our call: ship on deep sites.

• FAQPage — only where the FAQ is visible. Rich result limited to well-known government and health sites since August 2023. Our call: optional, no snippet.

• HowTo — nowhere. Rich result removed in 2023. Our call: skip.

• Product + Offer — commerce pages. Product snippets and merchant listings. Our call: ship if you sell.

We keep the visible FAQ section on posts like this one even without FAQPage markup. Question-and-answer prose is easy for answer engines to quote, and that value never depended on Google's rich-result card.

How do we keep JSON-LD in sync with what the page shows?

Generate the JSON-LD and the visible page from the same data object in the same request. Google's structured data guidelines require markup to describe content that is visible on the page, and markup that misrepresents the page can earn a manual action.

In the App Router, React's cache() makes the shared object cheap: generateMetadata and the page component call the same memoized function, so the title tag, the h1 and headline all come from one fetch. The page wraps getPostBySlug in React's cache(), awaits params in both generateMetadata and the default export, returns the title and description from generateMetadata, calls notFound() for unknown slugs, and renders <JsonLd data={postGraph(post)} /> above the h1 next to a time element whose dateTime is publishedAt.toISOString().

Dates are where drift showed up first for us. We store publishedAt and updatedAt as Date values and serialize them with toISOString(), which always includes a timezone designator. We bump dateModified only when the content changes meaningfully, not on every deploy.

Finally, a test against the rendered HTML catches what types cannot: a missing BlogPosting, a date without a timezone, or a headline that no longer matches the h1. It runs on Node's built-in test runner against next start. The test fetches a post page from BASE_URL, extracts every application/ld+json script with a regular expression, JSON.parses each one and flattens any @graph arrays, then asserts that exactly one BlogPosting exists, that datePublished matches an ISO 8601 pattern ending in Z or an offset, and that headline equals the text of the page's h1.

We still run Google's Rich Results Test and the Schema Markup Validator at validator.schema.org by hand when adding a new type; the CI test is what stops regressions after that.

FAQ

**Q:** Does JSON-LD improve Google rankings?

**A:** Not directly. Google has said structured data is not a general ranking factor; it makes pages eligible for rich results and helps Google understand entities, dates and authorship.

**Q:** Should we use next/script for JSON-LD in Next.js?

**A:** No. next/script manages loading strategies for executable scripts. A plain script tag with type="application/ld+json" rendered by a Server Component puts the data in the initial HTML, which is what the Next.js docs recommend.

**Q:** Can JSON-LD go in the body instead of the head?

**A:** Yes. Google reads JSON-LD anywhere in the HTML document, and React 19 renders the tag where you place it in the component tree.

**Q:** Is FAQPage markup still worth adding in 2026?

**A:** Not for rich results on most sites, because Google limited FAQ rich results to well-known government and health sites in August 2023. A visible FAQ section is still worth writing for readers and answer engines.

**Q:** Do ChatGPT and Perplexity read JSON-LD?

**A:** OpenAI and Perplexity have not documented how their crawlers use JSON-LD. Server-rendering it costs nothing and keeps it visible to crawlers that do not execute JavaScript.