Skip to main content
ASoc
Tutorial

Next.js JSON-LD: One Component, Seven Schema Types, 472 Pages

One 19-line Server Component emits seven schema types across 472 pages. The escape most tutorials omit, and two blocks we ship knowing they earn nothing.

The ASoc Team11 min read

JSON-LD in the Next.js App Router is a <script type="application/ld+json"> element rendered by a Server Component, with the payload stringified into dangerouslySetInnerHTML. There is no library and no next/head. In this codebase that is one 19-line component, reused to emit seven schema types and 1,032 blocks across a 472-page build — and the rendering was never the hard part.

The hard part is deciding what to assert. Structured data is a machine-readable claim about a page, and the two failure modes are claiming something false and claiming something that earns nothing. This post is the whole JSON-LD surface of a production storefront: the component, the escape most tutorials omit, the type inventory, and the two blocks we ship knowing Google cannot reward them yet.

The whole component

// src/components/atoms/JsonLd.tsx
export default function JsonLd({ data }: { data: Record<string, unknown> }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(data).replace(/</g, "\\u003c"),
      }}
    />
  );
}

That is the entire implementation. Every schema block on the site goes through it:

// src/app/templates/[slug]/page.tsx
return (
  <>
    {productData && <JsonLd data={productData} />}
    <JsonLd data={softwareApplicationLd(product)} />
    <JsonLd data={breadcrumbLd(product)} />
    <TemplateDetailTemplate product={product} />
  </>
);

Three things about that are worth saying out loud, because the tutorials for this keyword disagree with each other on all three.

It is a Server Component, so the markup is in the initial HTML. No useEffect, no next/script. Googlebot renders JavaScript, but crawlers that do not — and most AI crawlers do not — see structured data only if it arrives in the first response. Emitting it from a Server Component means it is in the prerendered HTML of all 472 pages, with nothing to execute.

It is not in <head>. Schema.org and Google both accept JSON-LD anywhere in the document, and next/head does not exist in the App Router anyway. Placing the script in the body, right where the component tree renders it, is correct and simpler. The one real constraint is that it must be the same markup on the server and the client, which a static object satisfies trivially.

It takes the object, not a string. data: Record<string, unknown> means every call site builds a plain object and the serialisation happens in one place — so the escaping below is applied once rather than remembered thirteen times.

The escape nobody mentions

JSON.stringify(data).replace(/</g, "\\u003c");

JSON.stringify is not HTML-safe. Inside a <script> element the browser's HTML parser is still looking for the closing tag, and it finds it by scanning for the literal characters </script. If any string in your payload contains that sequence, the element ends early: the rest of your JSON spills into the document as text, and anything after it is parsed as markup.

Escaping < as \u003c closes that off. The two are identical as far as JSON is concerned — "\u003c" is "<" after parsing, so Google's parser sees the value you meant — while the HTML parser never sees a < to get interested in. The official Next.js JSON-LD guide recommends the same escape, noting that JSON.stringify does not sanitise strings.

Whether you need it depends on where the payload comes from:

Payload sourceRiskWhat to do
Hardcoded literals in your repoNone todayEscape anyway — one .replace, and it survives the data becoming dynamic
A typed data file (catalog, registry)Low; an author could paste HTML into a descriptionEscape
A CMS or database fieldRealEscape, and validate the field
Anything a user submittedXSSEscape, validate, and reconsider putting it in schema at all

Ours is the second row — the payload is catalog and blog copy from typed files under src/data, written by us. The component's docstring records that explicitly, because dangerouslySetInnerHTML in a codebase with a rule against exactly that pattern needs a reason sitting next to it:

`data` is developer-authored static structured data (catalog/FAQ/brand copy),
never user input — so `dangerouslySetInnerHTML` here is safe and does NOT
violate [our no-raw-HTML rule] (which concerns user-controlled strings). The
`<` escaping below is belt-and-suspenders: it prevents a stray `</script>` in
any future data from breaking out of the block.

The inventory: seven types, five route kinds

RouteSchema blocksPages
src/app/layout.tsxOrganizationevery page
src/app/templates/[slug]/page.tsxProduct + SoftwareApplication + BreadcrumbList111
src/app/blog/[slug]/page.tsxBlogPosting + BreadcrumbList342
The 7 category hubs, via frameworkLandingJsonLd()BreadcrumbList + FAQPage7
src/app/blog/page.tsxBlog, with every post nested in blogPost1
src/components/organisms/Faq.tsxFAQPage1 (/pricing)

That is 1,032 structured-data blocks on top of the site-wide Organization, from one component, six builder functions and two static objects. Two patterns in that table are the ones worth lifting.

BreadcrumbList appears three times because breadcrumbs are per-route-shape, not global — the trail on a product page is Home → Templates → category hub → product and is built only from steps that exist:

// src/app/templates/[slug]/page.tsx
const hub = categoryHub(product.category);
const trail = [
  { name: "Home", item: BASE_URL },
  { name: "Templates", item: `${BASE_URL}/templates` },
  ...(hub ? [{ name: hub.eyebrow, item: `${BASE_URL}/${hub.slug}` }] : []),
  { name: product.name, item: `${BASE_URL}/templates/${product.slug}` },
];

The spread is doing real work. There is no hub for every category, and a breadcrumb step pointing at a URL that does not exist is a broken claim in a format nobody reads by eye. The trail describes a path a visitor can actually walk.

And nodes are joined by @id rather than repeated. Each post's BlogPosting carries "@id": "<url>#article" and an isPartOf pointing at the blog's own @id:

isPartOf: { "@id": `${BASE_URL}/blog#blog` },
mainEntityOfPage: { "@type": "WebPage", "@id": url },

The /blog index declares that #blog node once. The graph is stitched by reference, so the archive is described in one place and 342 posts point at it.

Two blocks that cannot earn a rich result

A product page emits both Product and SoftwareApplication for the same template. That looks redundant. It is not, and the reason is a Google eligibility rule that is easy to read past:

  • Product needs name and image, plus offers or review or aggregateRating — any one of the three.
  • SoftwareApplication needs offers.price and either review or aggregateRating.

These templates have a real price and no reviews yet. So Product qualifies on a truthful offers block alone, and SoftwareApplication is ineligible until real reviews exist. We ship it anyway, because it is the semantically accurate type for the thing — a web application you install — and it starts earning the day reviews land, with no code change.

The path not taken is the tempting one: invent an aggregateRating. That makes SoftwareApplication eligible immediately, and it is a fabricated claim in a machine-readable format, which is the specific thing Google issues manual actions for. A rich result built on an invented 4.8 is a liability with a countdown on it.

If you only want the one block that earns results today, the full Product shape — and the manual-action trap around reviews — is in Product schema in Next.js.

The claims we refuse to make

Four decisions in the builders are refusals, and each one is a line of code that removes schema:

No offer on a product you cannot buy. productLd returns null for a coming-soon template, and the call site renders nothing:

function productLd(product: TemplateProduct) {
  if (product.status !== "available" || !singleTierPrice) return null;
  // ...
}

One of 111 products is in that state today. Its page still emits SoftwareApplication and BreadcrumbList — it exists and sits in a hierarchy — but it advertises no price, because there is no checkout behind it.

No offer count that includes offers nobody can accept. The AggregateOffer counts purchasable tiers only:

offerCount: pricingTiers.filter((t) => t.price !== null && !t.comingSoon).length,

No fabricated byline. Every post's author is the organisation, not a person:

author: { "@type": "Organization", name: "ASoc", url: BASE_URL },

Inventing a named author to chase an E-E-A-T signal would be a lie in machine-readable form, and Organization is a valid author value. The team wrote them; that is what the markup says.

No image on BlogPosting. Each post has a generated OG card, and it is pure typography on a gradient. Declaring it as the article's representative image would present a title card as content. The field is omitted instead of filled with the only image available.

Keeping schema from drifting off the page

Structured data that contradicts the rendered page is worse than none, and the drift is always silent — nothing throws. Three habits prevent it here.

Derive the numbers, never retype them. The schema price is read from the same array that renders /pricing:

const singleTierPrice = pricingTiers.find((t) => t.id === "t1")?.price;

Hardcode "39" in the schema and it stays 39 through the next price change, and the page and its markup disagree until someone notices.

Derive the dates from the data's own shape. The changelog is prepended newest-first, so the original release is the last entry:

datePublished: product.changelog[product.changelog.length - 1]?.date,

Reaching for [0] there would report the latest version's date as the publication date on all 110 shipping products.

Build every URL from one origin helper. All url, image and item fields come from siteUrl(), which is also what metadataBase is built from. That matters because a page's JSON-LD url and its <link rel="canonical"> are two claims about the same thing and are compared as strings: a trailing slash in one environment variable once put https://host//templates/asoc-admin in our production structured data while the canonical read https://host/templates/asoc-admin. Nothing failed — the page served, the markup validated — and the two claims simply stopped matching. That is how we found the bug. The canonical half of the story is in canonical URLs in Next.js.

Mistakes and how they show up

MistakeSymptomFix
JSON.stringify with no < escapeA </script> in your data truncates the block and spills JSON into the page.replace(/</g, "\\u003c") in one component
Injecting the script from useEffectValidates in the browser, invisible to non-rendering crawlersRender it from a Server Component
SoftwareApplication with no reviewsValid markup, no rich result, everAdd Product with a truthful offers
Invented aggregateRatingA rich result, then a manual actionShip no rating until you have real ones
Schema price typed as a literalMarkup and page disagree after the next price changeRead it from the same data the page renders
changelog[0].date as datePublishedEvery product claims it was published at its last updateTake the oldest entry
Breadcrumb step for a page that does not existA trail pointing at a 404Build the trail conditionally
Relative URLs in url / imageFields resolve against the wrong origin or are droppedAbsolute, from one origin helper
@id repeated instead of referencedDuplicate nodes describing the same entityDeclare once, link with isPartOf / @id
Schema describing content that is not on the pageIgnored at best, penalised at worstOnly assert what a reader can see

Frequently asked questions

Do I need schema-dts or a JSON-LD library? No. schema-dts buys you TypeScript types for schema.org, which catches a misspelled property at build time — genuinely useful on a large graph, and a dev dependency you can skip on a small one. There is nothing to install for the rendering itself; it is the component above.

Does JSON-LD help with AI search as well as Google? It helps, and less than the structure of the page itself. Assistants lift clean prose, headings and tables far more readily than they parse your graph; structured data is a strong signal for entity and price facts specifically. Treat it as one layer — the AI-visibility work that moved the needle for us is in generative engine optimization.

Microdata or JSON-LD? JSON-LD, and it is not close. Google states a preference for it, and the practical argument is better: microdata is attributes woven through your markup, so a redesign silently breaks your structured data. JSON-LD is one object that a refactor of the JSX cannot touch.

How do I verify it before shipping? Google's Rich Results Test on a deployed URL tells you which rich result types the page is eligible for, which is the question that matters — the Schema Markup Validator only tells you the syntax parses. Run both: valid markup that qualifies for nothing is the most common outcome, and only one of the two tools will say so.

Landing-page templates to add this to

A marketing page is where structured data pays off fastest, because it is usually the one page carrying a price and an FAQ — so Organization, FAQPage and a truthful Offer all have something real to describe. Three to start from: ASoc Brief is a product-designer résumé and portfolio site, ASoc Byte an engineering-studio site with team and social-proof sections, and ASoc Canvas a no-code builder landing page with pricing tiers. Each is a Next.js App Router project, so the component above drops straight in and you are three objects from done.

Browse the full set of Next.js landing page templates, or the Tailwind landing page templates to bring your own framework. For the schema that earns the rich result, see Product schema in Next.js; for the canonical these URLs have to match, canonical URLs in Next.js.

Keep reading

Tutorial9 min read

Next.js Link: 40 of Them, and 22 CTAs the Lint Rule Cannot See

no-html-link-for-pages reads literal hrefs on raw anchors, so it never saw this site's button atom. Plus the internal URL that must stay an anchor: /api/download.

Read more