Skip to main content
ASoc
Tutorial

Product Schema in Next.js: JSON-LD That Earns Rich Results

Product needs only one of offers, review or aggregateRating — so a truthful offer qualifies you without inventing reviews. The shape, the validation, the manual-action trap.

The ASoc Team9 min read

A Next.js product page earns a rich result when its JSON-LD carries name, image, offers with a real price, and at least one of offers, review or aggregateRating. Product needs only one of those three — which means a truthful offers block qualifies you without inventing a single review. Emit it as a <script type="application/ld+json"> from the Server Component that already has the data.

This post covers what Google actually requires, how to emit JSON-LD in the App Router, the offer shape for a product sold at several price points, and the review shortcut that risks a manual action. The examples are the schema running on our own product pages.

What Google actually requires

The docs list a long property table, and most of it is optional. The requirements that decide whether you get a rich result at all:

PropertyRequired?Note
nameYesThe product name, not the page title
imageYesAbsolute URLs; multiple aspect ratios preferred
offers or review or aggregateRatingOne of the threeThis is the whole gate
offers.price + priceCurrencyWith offersA number as a string, no currency symbol
offers.availabilityRecommendedA schema.org URL, not the word "in stock"
sku / gtin / mpnRecommendedStrengthens product identity
brandRecommendedAn object, not a string

Two failures account for most invalid results in Search Console: relative image URLs, and a price containing a currency symbol or a comma.

Emitting JSON-LD in the App Router

Keep it in one small component so no page hand-rolls the script tag:

// src/components/atoms/JsonLd.tsx
export default function JsonLd({ data }: { data: Record<string, unknown> }) {
  return (
    <script
      type="application/ld+json"
      // JSON.stringify output inside a <script> is the documented pattern.
      // It is safe here because `data` is developer-authored catalog content.
      // If any field ever comes from user input, escape `<` first — a review
      // body containing "</script>" would otherwise break out of the tag.
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}

Then render it from the page, which is already a Server Component with the product in hand:

export default async function ProductPage({ params }) {
  const { slug } = await params;
  const product = getProduct(slug);
  if (!product) notFound();

  return (
    <>
      <JsonLd data={productLd(product)} />
      <JsonLd data={breadcrumbLd(product)} />
      <ProductDetail product={product} />
    </>
  );
}

Two things to know. The script does not need to be in <head> — Google reads JSON-LD anywhere in the document, including injected late. And it must be server-rendered: schema added by client-side JavaScript after hydration is unreliable, and there is no reason to defer markup you already have.

Build absolute URLs from one helper rather than concatenating in each call site:

const BASE_URL = siteUrl(); // trailing slash stripped, once
const imageUrls = (p: Product) => p.images.map((i) => `${BASE_URL}${i}`);

We learned that one the hard way: a trailing slash in the environment variable produced https://host//images/... in the JSON-LD while the crawled canonical had a single slash. The URLs no longer matched, and nothing errored.

The offer, when there is more than one price

If a product sells at exactly one price, use Offer. If it is reachable through several purchasable tiers or variants, AggregateOffer is the honest shape:

function productLd(product: Product) {
  return {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    description: product.description,
    image: imageUrls(product),
    sku: product.slug,
    brand: { "@type": "Brand", name: "ASoc" },
    offers: {
      "@type": "AggregateOffer",
      priceCurrency: "USD",
      lowPrice: String(product.lowestPrice),
      // Only tiers a shopper can actually buy today. Counting a coming-soon
      // tier claims an offer that cannot be accepted.
      offerCount: purchasableTiers.length,
      availability: "https://schema.org/InStock",
    },
  };
}

lowPrice alone is deliberate. "From $39" is the claim the page makes, so it is the claim the markup should make. Two rules keep this out of trouble:

  • The structured price must match the visible price. A mismatch is a policy violation, not a formatting nit, and it is trivially detectable.
  • Do not advertise an offer on something unbuyable. A coming-soon product should emit no offers at all — return null and skip the block rather than claiming InStock.
if (product.status !== "available") return null; // no offer, no rich result, no lie

The review trap

aggregateRating is the property that produces star ratings in search results, and it is the single most tempting field on the page.

Do not populate it with anything you did not collect. Ratings not shown on the page, or generated for products with no customers, are a structured data manual action — which removes rich results across the whole site, not just the offending page, and takes a reconsideration request to lift.

The workable position: ship offers now, which qualifies for a rich result on its own, and add aggregateRating the day you have real reviews rendered on the page. Our product pages do exactly this, which is why they carry a Product block with offers and no ratings.

The same restraint applies to SoftwareApplication, which Google will not turn into a rich result without a rating — emit it for semantic accuracy if it describes your product, but do not expect it to earn stars, and do not manufacture the rating that would.

BreadcrumbList is the cheapest structured data on a product page and the one most often wrong, because it describes a hierarchy the site does not have:

function breadcrumbLd(product: Product) {
  const hub = categoryHub(product.category);
  const trail = [
    { name: "Home", item: BASE_URL },
    { name: "Shop", item: `${BASE_URL}/shop` },
    ...(hub ? [{ name: hub.name, item: `${BASE_URL}/${hub.slug}` }] : []),
    { name: product.name, item: `${BASE_URL}/products/${product.slug}` },
  ];

  return {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: trail.map((step, i) => ({
      "@type": "ListItem",
      position: i + 1,
      name: step.name,
      item: step.item,
    })),
  };
}

Build the trail from data and map positions rather than hardcoding position: 3, or inserting a tier later means renumbering by hand. The conditional category step matters too: emit it only where that category page exists, so the trail always describes a path a visitor could walk.

Validating without guessing

Three checks, in order:

  1. Rich Results Test on the live URL — the only one that reports rich-result eligibility rather than mere validity.
  2. Schema Markup Validator for vocabulary errors the Google test ignores.
  3. Search Console → Enhancements → Products, a week after deploying. This is the one that surfaces failures at scale, and where invalid items show up with counts.

View source rather than using the browser inspector when checking manually. The inspector shows the hydrated DOM; view-source shows what the crawler was actually served.

Mistakes that cost us time

MistakeSymptomFix
Relative image URLs"Invalid image" in Rich Results TestAbsolute URLs from one siteUrl() helper
Trailing slash in the site URLhttps://host//images/...; nothing errorsStrip trailing slashes in one place
price: "$39.00"Invalid priceDigits only, as a string; currency in priceCurrency
availability: "In stock"IgnoredUse https://schema.org/InStock
brand: "ASoc"WarningAn object: { "@type": "Brand", name }
Offer on a coming-soon productClaims an unacceptable offerEmit no offers block
Invented aggregateRatingSite-wide manual actionOnly from reviews shown on the page
Schema injected client-sideIntermittently missingRender it from the Server Component

Frequently asked questions

Does structured data improve rankings? Not directly. It makes a page eligible for rich results, which changes how the listing looks and typically its click-through rate. Treat it as presentation, not as a ranking lever.

JSON-LD, Microdata, or RDFa? JSON-LD. Google recommends it, and it keeps the markup out of your JSX where it cannot be broken by a styling refactor.

Can Product and BreadcrumbList both go on one page? Yes, and they should. Use separate <script> blocks rather than an array — either parses, but separate blocks are easier to debug when only one is invalid.

How long until rich results appear? Days to weeks after the page is recrawled, and eligibility is never a guarantee. If Search Console reports the item as valid and no rich result appears, the markup is not the problem.

What about SoftwareApplication or Book on top of Product? You can emit a second, more specific type where it is genuinely accurate. Just know that most specific types require a rating for a rich result, so Product is usually the one doing the work.

Starting from a storefront that ships this

Product schema is a small file and a long list of ways to be subtly wrong — and the wrongness is invisible until Search Console reports it a week later.

Our Next.js shop templates ship product pages with the markup already correct. ASoc Lumen is a fine-jewellery storefront with a working bag; ASoc Bloom is a wellness store across supplements and devices; ASoc Glow is a clean-beauty shop with a full face, eyes and skincare catalog.

Browse all Next.js shop templates, or the Tailwind shop templates.

Keep reading

Tutorial12 min read

Product Variants in Next.js Without a Commerce Backend

Size and colour variants render fine from a typed file — until stock has to be authoritative. The option matrix, the URL-driven picker, the canonical, and where it breaks.

Read more
Tutorial8 min read

Next.js Redirects: Three APIs, and Why We Use Two of Them

Thirteen redirects, eight files, zero next.config.ts entries. Why every redirect() call here depends on auth state config-based redirects cannot see.

Read more
Tutorial9 min read

Next.js Rewrites: Three Phases, and the Four We Turned Down

318 pages and zero rewrites, with the compiled manifest to prove it. What each phase beats, why our seven hub pages stayed files, and the 308 redirect nobody configured.

Read more