Design optimized microdata (Schema.org, JSON-LD)

Structure and automate microdata to enrich your SEO pages and maximize their visibility on Google.

Wall of labeled wooden card catalog drawers

Use JSON-LD, generate it from the same data as the visible page, and only mark up what a visitor can actually see. Those three rules cover most of what goes wrong with structured data on programmatic sites.

A word on vocabulary, since the title of this chapter says microdata. Schema.org is the vocabulary. JSON-LD, Microdata and RDFa are three ways of writing it. Microdata mixes attributes into your HTML, which gets painful as soon as templates change. JSON-LD sits in a single script tag, and it is the format Google recommends when your setup allows it. On generated pages, it is the only one I use.

For the examples, say you run a directory of coworking spaces: one page per space, one page per city.

What structured data does and does not do

Structured data states explicitly what your page is about: this is a business, this is its address, this number is a rating out of 5, from this many reviews. A human reads that from the layout. A machine has to guess, and markup removes the guessing.

It can also make a page eligible for a richer display in Google, such as prices or review stars. Eligibility is all you get. Google decides what to show, and markup alone will not push a page up the rankings or get it indexed. Plan for it as a way to be understood better and sometimes to stand out, then judge it on click-through once it is live.

AI-generated answers read these pages too. Clean, explicit facts cannot hurt there, but Google’s documentation on AI features says no special markup is required to appear in them. So I write JSON-LD for correctness and for search features, and I make sure the facts are in the visible text as well.

Which types are worth generating

Rich result support changes. Before you build, check the current search gallery, because schema.org has hundreds of types and Google only displays a small share of them.

Type Use it for What to expect from Google
Product + Offer Product pages, marketplaces Price and availability can appear
Review / AggregateRating Real reviews you collect Stars can appear, under strict conditions
LocalBusiness A single physical location Feeds local understanding
Event Dated events with a real start date Event display can appear
Article / BlogPosting Guides and editorial pages Better understanding of author and dates
BreadcrumbList Nearly every page Cheap, clarifies the hierarchy
FAQPage Visible question and answer blocks No rich result for almost all sites

That last row needs spelling out, because many guides are still out of date on it. In August 2023 Google restricted FAQ rich results to authoritative government and health sites, and it has removed HowTo rich results. If an agency promises you FAQ dropdowns in the results, it is selling something that no longer exists for your site. A FAQ block on the page is still valuable for visitors and for AI answers, so keep it if the questions are real. I explain how to build those in the FAQ chapter. Whether you add FAQPage on top is minor: it describes the content accurately, it just will not earn a special display.

Open drawer full of index cards

A complete example for one page

A space page combines two entities in one @graph. The address, rating and breadcrumb all come from columns in the database.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "LocalBusiness",
      "@id": "https://example.com/coworking/lyon/sample-workspace#business",
      "name": "Sample Workspace",
      "url": "https://example.com/coworking/lyon/sample-workspace",
      "image": "https://example.com/images/sample-workspace.jpg",
      "priceRange": "€€",
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "1 rue de l'Exemple",
        "addressLocality": "Lyon",
        "postalCode": "69002",
        "addressCountry": "FR"
      },
      "aggregateRating": {
        "@type": "AggregateRating",
        "ratingValue": 4.6,
        "reviewCount": 38
      }
    },
    {
      "@type": "BreadcrumbList",
      "itemListElement": [
        {
          "@type": "ListItem",
          "position": 1,
          "name": "Coworking spaces",
          "item": "https://example.com/coworking"
        },
        {
          "@type": "ListItem",
          "position": 2,
          "name": "Lyon",
          "item": "https://example.com/coworking/lyon"
        },
        {
          "@type": "ListItem",
          "position": 3,
          "name": "Sample Workspace"
        }
      ]
    }
  ]
}
</script>

The last breadcrumb item has no item URL because it is the current page, which Google’s documentation allows. The rest of the trail should match the links the visitor sees, as described in the internal linking chapter.

Generate it from a function, with no empty fields

The classic programmatic bug is "ratingValue": null or a price of 0 on 3,000 pages, because a column was empty and the template printed it anyway. Build the object in code and add optional properties only when the data exists. The breadcrumb follows the same pattern, so I left it out here.

// src/lib/schema.ts
type Space = {
  name: string;
  url: string;
  street: string;
  city: string;
  postalCode: string;
  image?: string;
  rating?: number;
  reviewCount: number;
};

export function spaceSchema(space: Space) {
  const business: Record<string, unknown> = {
    '@type': 'LocalBusiness',
    '@id': `${space.url}#business`,
    name: space.name,
    url: space.url,
    address: {
      '@type': 'PostalAddress',
      streetAddress: space.street,
      addressLocality: space.city,
      postalCode: space.postalCode,
      addressCountry: 'FR',
    },
  };

  if (space.image) business.image = space.image;

  // No reviews on the page means no rating in the markup.
  if (space.rating && space.reviewCount > 0) {
    business.aggregateRating = {
      '@type': 'AggregateRating',
      ratingValue: space.rating,
      reviewCount: space.reviewCount,
    };
  }

  return { '@context': 'https://schema.org', '@graph': [business] };
}

In an Astro template, print it like this. Escaping < stops a stray value in your data from closing the script tag.

---
import { spaceSchema } from '../lib/schema';
const { space } = Astro.props;
const json = JSON.stringify(spaceSchema(space)).replace(/</g, '\\u003c');
---
<script type="application/ld+json" set:html={json} />

Other stacks reach the same result differently. On WordPress, Rank Math and Yoast SEO cover the common types, and custom fields handle the rest. On Webflow, CMS template pages accept custom code in the page settings, where you can insert collection fields directly into a JSON-LD block. On the Deux.io project, schemas were one of the 90+ page variables, handled the same way as titles and images.

Hand browsing a box of library cards

The rules that keep you out of trouble

Google’s structured data policies can be read in ten minutes. Three of them matter a lot at scale.

Markup must describe content visible on the page. A rating that only exists in JSON-LD is a spam signal, and a manual action on a template hits every page built from it.

Reviews must be real and tied to the item. Generating a plausible aggregateRating is fabrication, and so is copying an average from another site. A company marking up reviews of itself on its own site is also excluded from review stars, so on a directory the setup above is fine, whereas on a business’s own pages it would not be.

Dates and prices must be true. If your price changes in the database, the markup and the page should change in the same build.

Test by template, then by edge case

Run the Rich Results Test on one URL per template, then on the awkward rows: the space with no reviews, the one with no photo, the longest name, the name containing quotes or an ampersand. The Schema Markup Validator catches vocabulary errors beyond Google’s own features.

At scale, Screaming Frog can extract and validate structured data during a crawl. Search Console shows errors per rich result type once Google has processed your pages, and those counts are what I watch after a release. Ahrefs or Semrush can tell you which pages actually show a SERP feature. Add a build check that parses every JSON-LD block as well, since one unescaped character breaks a whole template, and the template automation chapter shows where that fits.

Your next step: list your page types, assign each one type from the table above after checking the search gallery, and map every property you want to a real column. Any property with no column gets dropped.