Skip to content
lazy devs
5 min readLazy Devs

Structured data that earns rich results

A practical guide to JSON-LD structured data in Next.js: which schema types actually earn rich results, how to wire them up, and the mistakes that get you ignored.

Structured data is one of the few SEO levers where you do real engineering work and get a measurable, visible payoff: stars under your search result, an FAQ accordion, a recipe card, a breadcrumb trail. It is also one of the most over-promised and under-implemented parts of technical SEO, mostly because people copy a snippet, never validate it, and assume Google rewards effort. Google rewards correctness, and it rewards markup that matches what is actually on the page.

What rich results actually require

A rich result is Google's term for a search listing with extra visual treatment. To get one, three things have to line up. You need a content type that Google supports for rich results (articles, products, FAQs, how-tos, recipes, events, breadcrumbs, job postings, and a handful more). You need valid structured data describing that content. And the markup has to reflect content a user can actually see on the page. That last point is where most implementations quietly fail.

Google reads three formats: JSON-LD, Microdata, and RDFa. Use JSON-LD. It lives in a single <script> tag, it does not pollute your markup, and it is the format Google explicitly recommends. There is no reason to interleave itemprop attributes through your JSX in 2025.

The vocabulary versus the eligibility list

A common confusion: schema.org defines thousands of types and properties. That is the vocabulary. Google's rich results are a much smaller subset with their own required and recommended fields. You can write perfectly valid schema.org markup that earns nothing because it is not a type Google features. Always check Google's specific documentation for the feature you want, not just schema.org. The two are related but not the same.

Wiring JSON-LD into Next.js

In the App Router, the clean approach is to render a <script type="application/ld+json"> directly inside your server component. Build the object in TypeScript so you get type checking and reuse, then serialize it.

// app/blog/[slug]/page.tsx
import type { BlogPosting, WithContext } from "schema-dts";
 
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
 
  const jsonLd: WithContext<BlogPosting> = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    image: [post.coverImage],
    author: {
      "@type": "Person",
      name: post.authorName,
      url: post.authorUrl,
    },
    publisher: {
      "@type": "Organization",
      name: "Lazy Devs Agency",
      logo: {
        "@type": "ImageObject",
        url: "https://lazydevsagency.com/logo.png",
      },
    },
    mainEntityOfPage: {
      "@type": "WebPage",
      "@id": `https://lazydevsagency.com/blog/${slug}`,
    },
  };
 
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* ... */}</article>
    </>
  );
}

Two things to call out. The schema-dts package (from Google) gives you typed schema objects, so @type autocompletes and required properties are checked at compile time. And dangerouslySetInnerHTML is correct here, not a code smell: you are intentionally injecting a JSON string, and JSON.stringify already escapes it safely. Do not template the JSON with string concatenation, because that is where XSS and broken markup come from.

For values that might contain a closing script sequence, escape it. A small helper avoids a whole class of injection bugs:

function toJsonLd(data: object): string {
  return JSON.stringify(data).replace(/</g, "\\u003c");
}

The schema types worth your time

Most sites only need three or four. Pick based on what you actually publish.

Article and BlogPosting

For editorial content. headline, datePublished, author, and image carry the weight. Keep headline under 110 characters or Google may ignore it. The author should be a real Person or Organization with a url, because Google has been pushing hard on author identity and experience signals.

Product with offers and reviews

This is the one that earns price, availability, and star ratings in the result. The trap: do not invent an aggregateRating unless those reviews are genuinely visible on the page. Google manually reviews product markup and will issue a structured data manual action if the rating has no on-page source. Real ratings from real reviews only.

const product: WithContext<Product> = {
  "@context": "https://schema.org",
  "@type": "Product",
  name: "Headless CMS Starter",
  description: "Production Next.js starter with a typed CMS layer.",
  offers: {
    "@type": "Offer",
    price: "149.00",
    priceCurrency: "USD",
    availability: "https://schema.org/InStock",
    url: "https://lazydevsagency.com/products/cms-starter",
  },
};

Cheap, almost universally eligible, and it replaces the ugly URL in the result with a readable trail. If you have any hierarchy at all, add it.

FAQPage

Worth a caution. Google restricted FAQ rich results to authoritative government and health sites in 2023, so for most commercial sites the FAQ accordion no longer shows. The markup is still valid and still helps Google understand the page, but do not implement FAQPage expecting the visual treatment. This is exactly why you check Google's current eligibility docs rather than a two-year-old tutorial.

The mistakes that get you ignored

Markup that describes content not on the page is the cardinal sin. If your JSON-LD claims a 4.8 star rating and the page shows no reviews, that is a violation, not a clever trick. The same goes for marking up content hidden behind tabs or inflating an event with dates that do not appear anywhere visible.

Stale dateModified is a quieter problem. If you hardcode it or let it drift from the real edit time, you lose the freshness signal that makes Google re-crawl. Derive it from your data source.

Multiple conflicting blocks on one page confuse the parser. One @type per entity, and if you have several, nest them or use an array under a single script tag rather than scattering contradictory ones.

Finally, forgetting to validate. Every change to your schema needs a pass through Google's Rich Results Test and a watch on the Enhancements reports in Search Console. The Rich Results Test tells you eligibility; the schema.org validator tells you vocabulary correctness. Use both, because they answer different questions.

A validation step you can automate

You do not have to validate by hand. Pull the rendered HTML in a test and assert the JSON-LD parses and carries the fields you expect:

import { expect, test } from "vitest";
 
test("blog post emits valid BlogPosting JSON-LD", async () => {
  const html = await renderPage("/blog/structured-data");
  const match = html.match(
    /<script type="application\/ld\+json">(.*?)<\/script>/s,
  );
  expect(match).not.toBeNull();
 
  const data = JSON.parse(match![1]);
  expect(data["@type"]).toBe("BlogPosting");
  expect(data.headline).toBeTruthy();
  expect(data.author?.name).toBeTruthy();
  expect(() => new Date(data.datePublished).toISOString()).not.toThrow();
});

That test catches the regressions that silently kill rich results: a renamed field, a null author, a malformed date. It runs in CI, costs nothing, and saves you from discovering the breakage three weeks later in a Search Console email.

The takeaway

Structured data pays off when the markup is honest and the type is one Google features. Start with BlogPosting or Product plus BreadcrumbList, build the objects in typed TypeScript, serialize them safely, and validate every change with the Rich Results Test and a CI assertion, the same discipline we apply to our Core Web Vitals checklist. Skip the types Google no longer shows, never describe content that is not on the page, and keep your dates honest. Do that, and rich results stop being luck.

If you want a second pair of eyes on your schema setup, that is the kind of thing we are happy to look at.

Related service

Performance & SEO Engineering

Top Core Web Vitals and search visibility, by design.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com