Back to Blog
JSON-LD Structured Data Done Right: Product Reviews, Brand Ratings, and GTINs in Next.js
Photo by Marten Bjork on Unsplash

You add JSON-LD to your Next.js product pages. The Rich Results Test passes with no errors. You deploy, wait three weeks, and your search listings still look like every other plain blue link. No stars. No ratings. Nothing.

The cause is almost never the JSON-LD syntax. It is the data inside it.


Why Google Ignores Technically Valid Structured Data

Google's structured data pipeline has two distinct gates. The first is syntactic — is your JSON-LD parseable and well-formed? The Rich Results Test checks this, and it is easy to pass. The second gate is semantic — does the data meet Google's quality and completeness requirements for that schema type? This is where most implementations fail silently.

For product and review rich results specifically, Google requires:

  • A schema type eligible for rich results (Product, LocalBusiness, Organization — not WebSite or WebPage)
  • Required fields populated with valid values
  • Strong product identifiers for e-commerce
  • A structured Brand entity, not a plain string
  • Enough reviews to meet Google's minimum thresholds

Failing any one of these means no rich results — even if Search Console reports your structured data as "Valid".


The Two Schema Types That Unlock Review Stars

For e-commerce: Product with aggregateRating

The Product type is the only schema that triggers star ratings on individual product pages. The aggregateRating must be nested directly inside the Product object — not alongside it at the root level.

// ✅ Correct — aggregateRating inside Product
const productJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  name: 'Merino Wool Crew Neck',
  aggregateRating: {
    '@type': 'AggregateRating',
    ratingValue: '4.6',
    reviewCount: '124',
    bestRating: '5',
    worstRating: '1',
  },
};

// ❌ Wrong — a separate AggregateRating at root level
const brokenSchema = {
  '@context': 'https://schema.org',
  '@type': 'AggregateRating', // Google won't associate this with any Product
  ratingValue: '4.6',
};

For brand and normal sites: Organization or LocalBusiness

If you want your brand's aggregate rating to appear in search, use Organization for non-physical businesses or LocalBusiness for businesses with a physical address. WebSite and WebPage schema types are not eligible for review rich results — full stop.

const brandJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'LocalBusiness', // or 'Organization'
  name: 'Your Brand Name',
  aggregateRating: {
    '@type': 'AggregateRating',
    ratingValue: '4.8',
    reviewCount: '312',
    bestRating: '5',
    worstRating: '1',
  },
};

The Product Identifier Problem: GTINs and MPNs

This is the issue that silently breaks e-commerce rich results for a large percentage of Next.js sites. Google requires products to carry at least one strong identifier:

  • gtin13 — 13-digit EAN barcode (international standard)
  • gtin12 — 12-digit UPC barcode (North American)
  • gtin8 — 8-digit short barcode
  • mpn — Manufacturer Part Number, assigned by the brand

Without one of these, Google may still index the structured data but will often withhold rich results. Search Console will flag the product with a "Missing identifier" warning under the Enhancements tab.

const productJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  name: 'Merino Wool Crew Neck',
  gtin13: '5901234123457', // preferred if available
  mpn: 'MW-CREW-NAVY-M',  // also acceptable, include both if you have them
  sku: 'MW-001-NVY-M',    // useful but not a strong identifier on its own
  // ...rest of schema
};

If your products genuinely have no GTINs — common for handmade, custom, or white-label items — omit those fields. You will see the Search Console warning, but it will not block indexing. The practical fix in this case is to always include at least an MPN or SKU, and make sure your product description clearly conveys its custom nature. For Google Merchant Center product feeds (a separate system from JSON-LD), you can suppress the warning by setting identifier_exists: no in the feed — but this attribute has no effect in JSON-LD schema and should not be added there.


The Brand Property: Type It, Do Not String It

This is the second silent failure. Many implementations pass brand as a plain string:

// ❌ Parses fine — but weakens Google's entity association
brand: 'Nike',

// ✅ What Google expects
brand: {
  '@type': 'Brand',
  name: 'Nike',
},

The typed Brand object lets Google associate the product with its brand entity in the Knowledge Graph. With a plain string, Google may still parse the value, but the brand connection is weaker and will not contribute to brand-level rich results or entity disambiguation in search.

The same rule applies to manufacturer and seller properties on Product — always type them as Organization or Brand objects, never plain strings.


Implementing JSON-LD in Next.js App Router

The cleanest pattern is a lightweight server component that injects a <script> tag directly into the page. Keep it at the page level, not in the root layout — structured data should be scoped to the content it describes.

// components/JsonLd.tsx
interface JsonLdProps {
  data: Record<string, unknown>;
}

export function JsonLd({ data }: JsonLdProps) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}
// app/products/[slug]/page.tsx
import { JsonLd } from '@/components/JsonLd';

export default async function ProductPage({
  params,
}: {
  params: { slug: string };
}) {
  const product = await getProduct(params.slug);
  const reviews = await getReviews(params.slug);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.images,
    sku: product.sku,
    gtin13: product.gtin ?? undefined,
    mpn: product.mpn ?? undefined,
    brand: {
      '@type': 'Brand',
      name: product.brandName,
    },
    offers: {
      '@type': 'Offer',
      price: product.price,
      priceCurrency: 'AUD',
      availability: product.inStock
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
    },
    aggregateRating:
      reviews.count > 0
        ? {
            '@type': 'AggregateRating',
            ratingValue: reviews.average.toFixed(1),
            reviewCount: reviews.count.toString(),
            bestRating: '5',
            worstRating: '1',
          }
        : undefined,
  };

  return (
    <>
      <JsonLd data={jsonLd} />
      {/* page content */}
    </>
  );
}

One critical point: this renders server-side, which is exactly what Google needs. If your JSON-LD is injected client-side — via useEffect, a tag manager, or any JavaScript that runs after the initial HTML is sent — Googlebot may not see it on its first crawl. SSR is not optional for structured data that needs to be indexed reliably.

For the brand schema, place it in the root layout or home page:

// app/layout.tsx
import { JsonLd } from '@/components/JsonLd';

const brandJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  name: 'Your Brand',
  url: 'https://yourdomain.com',
  logo: 'https://yourdomain.com/logo.png',
  sameAs: [
    'https://www.google.com/maps?cid=YOUR_GOOGLE_CID', // Google Business Profile
    'https://www.instagram.com/yourbrand',
    'https://www.linkedin.com/company/yourbrand',
  ],
  aggregateRating: {
    '@type': 'AggregateRating',
    ratingValue: '4.8',
    reviewCount: '312',
    bestRating: '5',
    worstRating: '1',
  },
};

The sameAs array pointing to your Google Business Profile URL is how Google connects your schema to its own review data. Without it, Google treats your aggregateRating as self-declared and weights it accordingly — which means it is less likely to be surfaced in search.


Validating Before You Wait Three Weeks

Two tools, used in this order:

Google Rich Results Test — paste your URL or raw JSON-LD and run the test. Look for the "Valid" status on the schema type you implemented, and pay close attention to warnings. A page can be "Valid" but still not show rich results — eligibility and display are separate decisions Google makes based on page quality, crawl history, and the completeness of the data.

Search Console → Enhancements — after deploying, check the Enhancements section. New structured data takes one to four weeks to appear here. Errors show as red, warnings as orange. Both can suppress rich results even when the Rich Results Test passes.

Common warnings to resolve:

  • ratingValue falls outside the specified bestRating and worstRating range
  • reviewCount is zero or missing
  • Missing GTIN or MPN on a product that appears to be a manufactured item
  • brand not recognised as a typed schema entity

Why Stars Still Don't Show Even With Valid Schema

Valid structured data is necessary but not sufficient. Google withholds rich results for several additional reasons:

Not enough reviews. Google's threshold is not published, but in practice you generally need at least three to five genuine user reviews before stars appear. A reviewCount of one or two is often not enough, regardless of schema quality.

Client-side only rendering. If your JSON-LD is injected by JavaScript after the initial page load, Googlebot's first crawl may not see it. The SSR pattern above handles this correctly.

Page quality signals. Google applies rich results more readily to pages with strong content signals. A thin product page with minimal copy and no reviews will not earn stars regardless of how complete the schema is.

Recrawl delay. Google does not recheck pages on demand. Changes to structured data can take two to six weeks to reflect in search results. If you have just fixed your schema, wait before assuming it is still broken.

Self-serving reviews. Reviews must originate from real users. If Google detects that Review items are authored by the brand itself, they will be suppressed.


Quick Reference Checklist

Product schema (e-commerce)

  • @type: 'Product' at the root
  • aggregateRating nested inside Product, not at the root level
  • brand is &#123; "@type": "Brand", "name": "..." &#125; — not a plain string
  • At least one of: gtin13, gtin12, gtin8, mpn
  • If no identifier exists: include a sku and clear product description
  • ratingValue is between worstRating and bestRating
  • reviewCount matches actual review count — never inflated
  • JSON-LD rendered server-side, not via useEffect

Brand and site schema

  • @type: 'Organization' or 'LocalBusiness' — not WebSite
  • sameAs includes Google Business Profile URL
  • aggregateRating nested inside the organisation object
  • Only include aggregateRating if you have real, externally verifiable reviews

Validation

  • Passes Google Rich Results Test with no errors or warnings
  • Check Search Console Enhancements after one to two weeks
  • At least three to five real user reviews before expecting stars to appear

Key Takeaways

  • The Rich Results Test only validates syntax — Google's semantic gate is where most implementations fail without any error message.
  • Missing GTINs or MPNs are the most common reason product rich results do not appear on e-commerce sites.
  • brand: 'Nike' and brand: &#123; "@type": "Brand", "name": "Nike" &#125; are not equivalent to Google — always use the typed object.
  • aggregateRating must be nested inside your root schema type, not alongside it at the top level.
  • JSON-LD must render server-side in Next.js — client-only injection is invisible to Googlebot on first crawl.

Sources & References

  1. Google Search Central. "Understand how structured data works". Google LLC.
  2. Google Search Central. "Product structured data". Google LLC.
  3. Google Search Central. "Organization structured data". Google LLC.
  4. Google Search Central. "Fix Search Console structured data issues". Google LLC.
  5. Schema.org Community Group. "Product". Schema.org.
  6. Schema.org Community Group. "AggregateRating". Schema.org.
  7. Google Search Central. "Rich Results Test documentation". Google LLC.
Older Post

How I Use AI to Audit and Fix WCAG 2.2 Compliance Issues (An Australian Developer's Workflow)

Suggested Reading

Architectural Note:This platform serves as a live research laboratory exploring the future of Agentic Web Engineering. While the technical architecture, topic curation, and professional history are directed and verified by Maas Mirzaa, the technical research, drafting, and code execution for this post were augmented by Claude (Anthropic). This synthesis demonstrates a high-velocity workflow where human architectural vision is multiplied by AI-powered execution.