---
title: "Structured data (JSON-LD): what to emit, required properties, builder (seo)"
description: "Google Search gallery as of 2026-09 (gallery updated 2026-06-15; Software app page updated 2026-09-08)."
canonical: https://void-design.vercel.app/docs/seo/structured-data
lastModified: 2026-09-16
---

# Structured data (JSON-LD): what to emit, required properties, builder

Google Search gallery as of 2026-09 (gallery updated 2026-06-15; Software app page updated 2026-09-08).
Canonical code: `templates/next/src/components/json-ld.tsx` and `templates/next/src/lib/schema.ts`.

## 1. Policy (non-negotiable)

1. **Only mark up what is visible on the page and true.** `headline` ≈ h1, `name` ≈ visible name, `offers.price` appears in the text, `aggregateRating.ratingValue` and count appear in the text, `author.name` matches the byline.
2. **Never invent** ratings, reviews, prices, offers, authors or FAQs. No self-serving reviews on your own Organization or LocalBusiness. Invented ratings can bring a manual action.
3. **Server-render it** in the raw HTML. JS-injected markup is invisible to non-Google crawlers and makes Shopping crawls less reliable.
4. **One `@graph` per page with stable absolute `@id`s**: `https://site/#organization`, `https://site/#website`, `https://site/blog/x#article`. Other nodes reference them by `{ "@id": … }`. Define Organization and WebSite once per page, never as duplicates with different data.
5. **Escape `<`** in the serialized JSON. Use a native `<script>`, not `next/script`.
6. Dates are ISO 8601 **with timezone** (`2026-09-01T09:00:00Z`) and `dateModified ≥ datePublished`.
7. Mobile and desktop serve identical markup.

## 2. What still produces results

| Use | Type | Where |
|---|---|---|
| Site name + knowledge panel signals | `Organization` + `WebSite` | Home page (required there); emitting from the root layout on every page is fine |
| Breadcrumb trail in results | `BreadcrumbList` | Any nested page |
| Article/top stories enhancements | `Article` / `BlogPosting` / `NewsArticle` / `TechArticle` | Posts, docs |
| Software app rich result | `SoftwareApplication` / `WebApplication` / `MobileApplication` | **Only with real `aggregateRating` or `review`** |
| Product snippets (review/editorial pages) | `Product` with `review`/`aggregateRating`/`offers` | Review or comparison pages |
| Merchant listings (buyable) | `Product` with `Offer` | Pages where users buy |
| Author identity | `ProfilePage` + `Person` | Author pages |
| Video results | `VideoObject` | Pages whose main content is a video |
| Events | `Event` | Event pages |
| Forums / Q&A | `DiscussionForumPosting`, `QAPage` | Community content |

**Removed. Don't add for rich results:**

| Date | Removed |
|---|---|
| 2023-08/09 | HowTo |
| 2024-11-29 | Sitelinks search box (`WebSite.potentialAction` `SearchAction`) |
| 2025-06-12 | ClaimReview, course info, estimated salary, learning video, special announcement, vehicle listing |
| 2025-11-05 | Practice problem; Dataset limited to Dataset Search |
| 2026-05-07 | **FAQ rich results** (docs removed 2026-06-15) |

Existing FAQPage markup is harmless, but don't generate new FAQPage for rich results. Plain-HTML Q&A sections (h2 question + answer paragraph) still help AI answers. Don't add `speakable` for effect either.

## 3. Required and recommended properties

| Type | Required | Strongly recommended |
|---|---|---|
| `Article` / `BlogPosting` / `NewsArticle` / `TechArticle` | none | `headline`, `image` (≥ 50,000 px²; 16:9, 4:3, 1:1 variants), `datePublished`, `dateModified`, `author` (`Person`/`Organization` with `name` + `url` or `sameAs`), `publisher` |
| `BreadcrumbList` | `itemListElement[]` of `ListItem` with `position` (from 1, no gaps), `name`, `item` (absolute; last item may omit it) | ≥ 2 items |
| `Organization` | none | `name`, `url`, `logo` (≥ 112×112, crawlable), `sameAs` (official profiles), `description`; `address`/`contactPoint` only if real |
| `WebSite` | `name`, `url` (canonical home) | `alternateName`, `publisher` → `#organization`. One WebSite node; must be present on the home page |
| `SoftwareApplication` | `name`, `offers.price` (0 if free), **`aggregateRating` or `review`** | `applicationCategory` (e.g. `DeveloperApplication`), `operatingSystem`, `offers.priceCurrency` |
| `Product` (snippet) | `name` + one of `review` / `aggregateRating` / `offers` | `offers.price`, `priceCurrency`, `availability` |
| `Product` (merchant) | `name`, `image`, `offers` (`Offer`, not `AggregateOffer`, with `price` + `priceCurrency`) | `brand.name`, `gtin`/`mpn`, `shippingDetails`, `hasMerchantReturnPolicy`, `availability` |
| `ProfilePage` | `mainEntity` (`Person`/`Organization` with `name`) | `dateCreated`, `dateModified`, `mainEntity.image`, `mainEntity.sameAs` |
| `VideoObject` | `name`, `thumbnailUrl`, `uploadDate` | `description`, `duration` (ISO 8601), `contentUrl` or `embedUrl` |

Author best practice: `author` is `Person` or `Organization` (never `Thing`), one entity per author, `name` only (no "Posted by", no job title in `name`), plus `url` to a profile page.

## 4. Component (XSS-safe)

```tsx
// src/components/json-ld.tsx: Server Component (same as templates/next/src/components/json-ld.tsx)
import type { SchemaGraph, SchemaNode } from "@/lib/schema";

export function JsonLd({ data }: { data: SchemaGraph | (SchemaNode & { "@context": string }) }) {
  return (
    <script
      type="application/ld+json"
      // JSON.stringify doesn't escape "<"; a string containing </script> would break out of the tag
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
    />
  );
}
```

Verified: a description of `Probe </script><script>alert(1)</script>` is emitted as `\u003c/script>…`, which is valid JSON and inert.

## 5. `@graph` builder

The canonical copy is `templates/next/src/lib/schema.ts`. It exports `graph`, `organization`, `website`, `webpage`, `breadcrumb`, `person`, `article` and `softwareApplication`. The condensed version below uses the same names and `@id`s, plus `profilePage`:

```ts
// src/lib/schema.ts
import { absoluteUrl } from "@/lib/seo";
import { site } from "@/site.config";

type Ref = { "@id": string };
export type SchemaNode = { "@type": string; "@id"?: string; [key: string]: unknown };
export type SchemaGraph = { "@context": "https://schema.org"; "@graph": SchemaNode[] };

export const ids = {
  organization: `${site.url}/#organization`,
  website: `${site.url}/#website`,
  webpage: (path: string) => `${absoluteUrl(path)}#webpage`,
  person: (slug: string) => `${site.url}/#person-${slug}`,
};
const ref = (id: string): Ref => ({ "@id": id });

export const graph = (...nodes: SchemaNode[]): SchemaGraph => ({ "@context": "https://schema.org", "@graph": nodes });

export const organization = (): SchemaNode => ({
  "@type": "Organization", "@id": ids.organization, name: site.organization.name, url: `${site.url}/`,
  logo: absoluteUrl(site.organization.logo),
  ...(site.socials.length ? { sameAs: site.socials.map((s) => s.href) } : {}),
});

export const website = (): SchemaNode => ({
  "@type": "WebSite", "@id": ids.website, name: site.name, url: `${site.url}/`,
  description: site.description, inLanguage: site.lang, publisher: ref(ids.organization),
  // no potentialAction/SearchAction: sitelinks search box removed 2024-11
});

export const webpage = (i: { path: string; name: string; description: string; dateModified?: string }): SchemaNode => ({
  "@type": "WebPage", "@id": ids.webpage(i.path), url: absoluteUrl(i.path), name: i.name, description: i.description,
  isPartOf: ref(ids.website), inLanguage: site.lang, ...(i.dateModified ? { dateModified: i.dateModified } : {}),
});

export const breadcrumb = (items: { name: string; path?: string }[]): SchemaNode => ({
  "@type": "BreadcrumbList",
  itemListElement: items.map((item, i) => ({
    "@type": "ListItem", position: i + 1, name: item.name, ...(item.path ? { item: absoluteUrl(item.path) } : {}),
  })),
});

export const person = (i: { slug: string; name: string; url?: string; sameAs?: string[] }): SchemaNode => ({
  "@type": "Person", "@id": ids.person(i.slug), name: i.name,
  ...(i.url ? { url: i.url } : {}), ...(i.sameAs?.length ? { sameAs: i.sameAs } : {}),
});

export const article = (i: {
  path: string; headline: string; description: string; datePublished: string; dateModified: string;
  author: Ref | SchemaNode; image?: string; type?: "Article" | "BlogPosting" | "TechArticle";
}): SchemaNode => {
  const url = absoluteUrl(i.path);
  return {
    "@type": i.type ?? "BlogPosting", "@id": `${url}#article`, headline: i.headline, description: i.description,
    datePublished: i.datePublished, dateModified: i.dateModified, mainEntityOfPage: ref(ids.webpage(i.path)),
    author: i.author, publisher: ref(ids.organization), image: [i.image ?? `${url.replace(/\/$/, "")}/opengraph-image`],
  };
};

/** Valid without a rating, but only eligible for the Software app rich result WITH a real, visible rating. */
export const softwareApplication = (i: {
  name: string; category: string; operatingSystem: string;
  price?: { amount: number; currency: string }; rating?: { value: number; count: number };
}): SchemaNode => ({
  "@type": "SoftwareApplication", "@id": `${site.url}/#software`, name: i.name,
  applicationCategory: i.category, operatingSystem: i.operatingSystem, publisher: ref(ids.organization),
  ...(i.price ? { offers: { "@type": "Offer", price: i.price.amount, priceCurrency: i.price.currency } } : {}),
  ...(i.rating ? { aggregateRating: { "@type": "AggregateRating", ratingValue: i.rating.value, ratingCount: i.rating.count } } : {}),
});

export const profilePage = (i: { path: string; person: SchemaNode; dateModified?: string }): SchemaNode => ({
  "@type": "ProfilePage", "@id": `${absoluteUrl(i.path)}#profile`, url: absoluteUrl(i.path),
  mainEntity: i.person, ...(i.dateModified ? { dateModified: i.dateModified } : {}),
});
```

Usage:

```tsx
// Organization + WebSite: once site-wide. The template emits them from the root layout; home-page-only is also fine.
<JsonLd data={graph(organization(), website())} />

// src/app/page.tsx (home)
<JsonLd data={graph(webpage({ path: "/", name: site.tagline, description: site.description, dateModified: "2026-09-01" }))} />

// src/app/blog/[slug]/page.tsx
const author = person({ slug: post.author.slug, name: post.author.name, url: absoluteUrl(`/authors/${post.author.slug}`) });
<JsonLd data={graph(
  webpage({ path: `/blog/${post.slug}`, name: post.title, description: post.description, dateModified: post.updated }),
  article({ path: `/blog/${post.slug}`, headline: post.title, description: post.description,
            datePublished: post.published, dateModified: post.updated, author: { "@id": ids.person(post.author.slug) } }),
  author,
  breadcrumb([{ name: "Blog", path: "/blog" }, { name: post.title }]),
)} />

// src/app/docs/[...slug]/page.tsx
<JsonLd data={graph(article({ ...docArticle, type: "TechArticle" }), breadcrumb(doc.trail))} />
```

Product page with a real price but **no** reviews: emit `Product` with `offers` (snippet-eligible) or `softwareApplication` without `rating` (valid, but no rich result). Never add a rating to get stars.

## 6. Validate

- Offline (what `void geo` does): JSON parses; `@type`s exist in schema.org; the required props from §3 are present; `@id` references resolve in-page or to the home graph; visible-text match for name, price, rating and author; dates carry a timezone; BreadcrumbList positions are 1..n.
- Google Rich Results Test (manual, no API): https://search.google.com/test/rich-results. Schema Markup Validator: https://validator.schema.org.
- After launch: Search Console → Enhancements.

## Sources

- https://developers.google.com/search/docs/appearance/structured-data/search-gallery
- https://developers.google.com/search/docs/appearance/structured-data/sd-policies
- https://developers.google.com/search/docs/appearance/structured-data/software-app
- https://developers.google.com/search/docs/appearance/structured-data/article · …/breadcrumb · …/organization · …/product-snippet · …/merchant-listing · …/profile-page · …/video
- https://developers.google.com/search/docs/appearance/site-names
- https://developers.google.com/search/updates (deprecation changelog)
- https://nextjs.org/docs/app/guides/json-ld
