A Second Language for Your Page: Emitting JSON-LD in the Server Render
Your product page shows a name, a price, five stars, and twelve reviews to humans. To a search engine's parser it is an undifferentiated soup of divs — the price is just text that happens to sit near the name. JSON-LD fixes the ambiguity by shipping a machine-readable description of the page's entities alongside the markup, in a script block that any HTML-only crawler can read:
// app/product/[slug]/page.tsx
import type { Metadata } from "next";
import { getProduct } from "@/lib/products";
export default async function ProductPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const product = await getProduct(slug);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.images,
description: product.blurb,
sku: product.sku,
brand: { "@type": "Brand", name: product.brand },
aggregateRating: {
"@type": "AggregateRating",
ratingValue: product.rating,
reviewCount: product.reviewCount,
},
offers: {
"@type": "Offer",
price: product.price,
priceCurrency: "USD",
availability: "https://schema.org/InStock",
},
};
return (
<main>
<ProductView product={product} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
</main>
);
}
Three details in that file are load-bearing. @context: "https://schema.org" declares the vocabulary; @type names the entity type, and every property you set must be one schema.org defines for that type — invent ratingStars and the parser ignores it. The payload is emitted by a Server Component, so it is in the initial HTML: crawlers and validators that fetch raw HTML see it with no JavaScript, the same guarantee that carries the Metadata API's tags. And the .replace(/</g, "\u003c") is not decoration — a </script> sequence inside the JSON (plausible once descriptions accept user input) closes the tag early and breaks the document; escaping every < as the JSON-compatible \u003c neutralizes it while staying valid JSON.
Why a separate script block instead of microdata attributes scattered through the DOM? JSON-LD is decoupled from presentation: the same data object can be built from the page's fetched record and rendered once, while the visible UI uses the same record independently. The two representations share a source of truth without sharing markup — which is exactly the property that keeps you out of the mismatch trap in the second chapter. It is also the format Google documents as preferred, and the one every validator accepts.
Where the object comes from is a design decision with a right answer: derive it from the same query the page renders from, like the cache()-shared accessor of Dynamic generateMetadata — one read, two consumers, no drift. A hand-built JSON object maintained next to a component that renders different fields is how price: 29.99 in the payload survives a UI change to $34.99, and every downstream consumer (rich results, internal tools parsing your structured data, future features) inherits the lie.
A structural note for app architecture: JSON-LD belongs at the page level, once per entity type — the same way you would not ship two <title> tags. A page describing a product and a review and a breadcrumb trail emits an array of nodes ([product, breadcrumbs]) or nested hasPart references rather than three competing top-level scripts. Breadcrumbs earn their own treatment: a BreadcrumbList mirroring the visible trail is the structured-data twin of the URL hierarchy crawlers already infer from your paths.
Rich Results: What Engines Do With Valid Data — and What They Do With Bad Data
The reason to bother: valid structured data can earn a page a rich result — the enhanced search listing with star ratings, price ranges, faq accordions, breadcrumbs, recipe times — and those listings measurably change click-through. Structured data is not a ranking factor; it is a rendering factor that decides how an eligible result displays. The type set that pays off for most product/content sites:
| @type | Qualifies for | Key required properties |
|---|---|---|
Product + Offer |
price, availability in results | name, image, offers.price + priceCurrency, availability URL |
Product + AggregateRating |
star row under the title | ratingValue, reviewCount (>= a handful before it shows) |
Article / BlogPosting |
headline, date, author byline | headline, datePublished, image, author |
BreadcrumbList |
the / Home / Blog / Post trail |
itemListElement with positions and names |
FAQPage |
expandable Q&A block | mainEntity question/answer pairs — tightly policed for spam |
Organization / Person |
knowledge-panel-ish identity | name, logo/sameAs profiles |
The enforcement model is the part to understand coldly. Google does not reject invalid markup with an error you can see — it ignores what it cannot parse and penalizes what it considers spammy: markup for content that is not visible on the page, ratings you did not collect, prices that differ from the visible product. The ladder is silent degradation first (markup ignored, rich result never appears), then manual actions for organized abuse (markup-for-rewards programs, faq spam at scale). That is why the third chapter's single-source-of-truth rule is an integrity rule, not a style preference: the parser cross-checks the payload against the rendered page, and divergence is the failure mode with teeth.
Validation is a two-tool ritual. Google's Rich Results Test checks eligibility for enhanced display for a given URL or snippet; the Schema.org validator checks semantic validity against the vocabulary. Run both on a staging URL before shipping — the first names missing required properties for specific rich result types ("offers.price or offers.highPrice is required"), the second catches type errors the eligibility test tolerates. Neither is optional ceremony: the cheapest debugging for structured data happens before deploy, because post-deploy there is no error log — just absent rich results you will notice weeks later in traffic analytics.
An honest framing for interviews and for planning: rich results are granted, revoked, and reshaped by platforms on their own schedule — FAQPage visibility was sharply narrowed, HowTo is deprecated in most contexts, and product markup requirements tightened around merchant integration. Treat structured data as a rendering layer you keep truthful, not a growth hack you extract once. The durable core is the discipline: every entity the page shows gets an accurate machine-readable description, derived from the same data the human sees. When a rich result type is deprecated, that discipline costs you nothing; when a new one ships (as AI surfaces increasingly consume the same schema.org vocabulary), it makes you early.
One deployment note ties back to the rendering phase: because the payload ships with the server-rendered HTML, its freshness follows the page's regime — a prerendered product page carries build-time prices in both the visible markup and the JSON-LD, so Incremental Static Regeneration or dynamic rendering is what keeps them current together. A stale price in the payload is doubly bad: wrong for the rich result and a mismatch signal against the (correctly updated) visible page.
Production Pattern: One Query, Two Consumers — UI and Structured Data
The implementation that survives contact with real teams puts the JSON-LD builder beside the data it describes, fed by the same read. A complete product page:
// lib/product-jsonld.ts — pure function, unit-testable
export function buildProductJsonLd(product: Product) {
return {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.images.map((i) => i.url),
description: product.blurb.slice(0, 500),
sku: product.sku,
gtin13: product.gtin,
brand: { "@type": "Brand", name: product.brand.name },
aggregateRating: product.reviewCount > 0 && {
"@type": "AggregateRating",
ratingValue: product.rating,
reviewCount: product.reviewCount,
},
offers: {
"@type": "Offer",
url: `/product/${product.slug}`,
price: product.price.toFixed(2), // string, as schema.org expects
priceCurrency: "USD", // ISO 4217 — "USD", not "$"
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
itemCondition: "https://schema.org/NewCondition",
},
};
}
// app/product/[slug]/page.tsx
export default async function ProductPage(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params;
const product = await getProduct(slug); // one read: UI and JSON-LD share it
if (!product) notFound();
return (
<main>
<ProductView product={product} />
<ProductJsonLd product={product} /> {/* the script component */}
</main>
);
}
Field-level details that validators actually flag, learned the expensive way: price is a string (schema.org's Number type accepts it, and it sidesteps float formatting); priceCurrency is ISO 4217 with no symbols; availability and itemCondition are not free text but full schema.org URLs (https://schema.org/InStock) — the single most common typo class. An Offer without a price is ineligible; a price without priceCurrency is ineligible; both errors appear as absent rich results, never as exceptions.
The pattern extends without new machinery when a page carries more than one entity. A product page that renders a review list and a breadcrumb trail emits an array of nodes, each built by its own pure function and joined at render — const jsonLd = [buildProductJsonLd(product), buildBreadcrumbJsonLd(trail)]; — and validators accept multiple top-level nodes without complaint. The failure to avoid is emitting the same entity twice at top level (the product script from the page and another product script from a review component); parsers pick one and your rating markup becomes a coin flip. One owner per entity type, assembled in one place — the same composition rule as layouts, applied to data a machine reads.
The integrity guarantees come from structure, not vigilance. Because buildProductJsonLd is a pure function of the fetched record — the same record the visible component renders — the two cannot diverge on price or stock: one query, one truth, two projections. Unit-test the builder with a fixture product (cheap, and it catches a renamed field long before search console does), and reject the alternative architecture — a static JSON blob edited by hand — in code review on drift risk alone. The final consistency check runs at render: if the visible price and the payload price are computed from different fields, you have reintroduced the bug this design exists to prevent.
Completing the SEO stack: the title and social card come from generateMetadata, the crawl surface from Sitemaps and robots.txt, and the entity description from this payload — three layers, all server-rendered, all derived from the same data, none requiring client JavaScript. That closure is the practical answer to "how does a Next.js app get found": not one feature, but a coherent set of server-rendered signals that agree with each other. Beyond this phase, the roadmap turns to keeping the users those signals attract: sessions and identity are where Authentication Providers begins.
Quiz
1. Why does the JSON-LD script use JSON.stringify(jsonLd).replace(/</g, '\\u003c')?
2. A product page's JSON-LD says price: 19.99 while the rendered page shows $24.99. What is the most likely outcome?
3. Which JSON-LD snippet is correct for an in-stock offer?
4. Where should a page's Product JSON-LD data come from?
Flashcards
Question
What is JSON-LD structured data and where is it rendered in a Next.js app?
Click to reveal answer
Answer
A script block of schema.org-described entities ({ "@context": "https://schema.org", "@type": "Product", ... }) emitted by a Server Component via dangerouslySetInnerHTML — part of the initial server-rendered HTML, visible to JS-less crawlers and validators, decoupled from the visible markup.
Question
Why escape '<' inside the JSON-LD payload?
Click to reveal answer
Answer
A '</script>' sequence in any string field (reviews, descriptions) would close the script tag early and inject raw content into the page. JSON.stringify(jsonLd).replace(/</g, '\\u003c') removes the hazard while keeping the JSON valid.
Question
What do rich results reward, and what happens to bad markup?
Click to reveal answer
Answer
Valid entity markup can earn enhanced listings (stars, prices, breadcrumbs, FAQ blocks) — a rendering factor, not a ranking factor. Invalid markup is ignored silently; spammy markup (invisible content, fake ratings, mismatched prices) gets rich results withheld and, at scale, manual actions.
Question
The field-level details validators flag most in Product offers.
Click to reveal answer
Answer
price as a string ("29.99"), priceCurrency as bare ISO 4217 ("USD", never "$"), availability and itemCondition as full schema.org URLs (https://schema.org/InStock), and offers must include a price. Errors surface as absent rich results, never exceptions — hence pre-deploy validation.
Question
The single-source-of-truth rule for structured data.
Click to reveal answer
Answer
Build the payload with a pure function from the same fetched record the visible components render (React cache()-shared with the page and metadata), so price/stock drift is structurally impossible; unit-test the builder with a fixture. Freshness follows the page's rendering regime — stale ISR pages stale both.
Revision Notes
Key Takeaways
- 1. JSON-LD is a server-rendered script of schema.org entities — the machine-readable twin of your visible content, in the initial HTML
- 2. Escape '<' as \u003c in the payload: user-supplied text containing </script> would otherwise break out of the tag
- 3. Structured data earns rich results (a rendering factor, not a ranking factor); mismatched or spammy markup is ignored or penalized
- 4. Field discipline decides eligibility: string prices, ISO 4217 currencies, full-URL availability values, prices inside offers
- 5. Build the payload from the same fetched record the page renders — one query, two projections, unit-tested builder, no drift
Interview Tips
- • "What is structured data and why does it matter?" — schema.org entities in a JSON-LD script, server-rendered; rich results change CTR without being a ranking factor; state the enforcement model (ignore bad, penalize spam) to show depth
- • "How do you keep JSON-LD accurate?" — pure builder over the cache()-shared record the UI renders; unit-test with fixtures; validate pre-deploy (Rich Results Test + schema.org validator) since there is no runtime error to catch
- • "JSON-LD vs microdata vs RDFa" — JSON-LD is decoupled from markup, Google's preferred format, and one object built from data rather than attributes sprinkled through components; the rest are legacy formats
- • "A rich result disappeared after our redesign" — diff the payload against the visible page for required properties and price/stock agreement, then re-run the eligibility test; mention that platforms deprecate types, so the truthful-data discipline is the durable part
Cheat Sheet
JSON-LD Structured Data
Emit it (server-rendered, once per page):
const jsonLd = { "@context": "https://schema.org", "@type": "Product", name, offers: { "@type": "Offer", price: "29.99", priceCurrency: "USD", availability: "https://schema.org/InStock" } };
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />
The <-escape prevents '' breakout from user-supplied strings.
Type → rich result: Product+Offer → price/availability · +AggregateRating → stars · Article → byline/dates · BreadcrumbList → trail · FAQPage → Q&A block (policed) · Organization/Person → identity.
Integrity rules: payload must match visible content — mismatch = ignored or spam-flagged (manual actions at scale). Derive from the same record the UI renders via a pure, unit-tested builder + cache()-shared fetch; never hand-edit a JSON blob.
Field gotchas: price string · priceCurrency ISO 4217 · availability/itemCondition are full schema.org URLs · Offer needs a price · array of nodes for multiple entities on one page.
Process: stage → Rich Results Test (eligibility) + Schema.org validator (semantics) → ship. No error log exists post-deploy — absence of rich results is the only symptom. Freshness follows the page's regime (ISR/dynamic keeps payload and UI current together).
Stack closure: metadata (title/card) + sitemap/robots (crawl) + JSON-LD (entity) — all server-rendered, all from one data layer.