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)
- Only mark up what is visible on the page and true.
headline≈ h1,name≈ visible name,offers.priceappears in the text,aggregateRating.ratingValueand count appear in the text,author.namematches the byline. - 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.
- Server-render it in the raw HTML. JS-injected markup is invisible to non-Google crawlers and makes Shopping crawls less reliable.
- One
@graphper page with stable absolute@ids: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. - Escape
<in the serialized JSON. Use a native, notnext/script. - Dates are ISO 8601 with timezone (
2026-09-01T09:00:00Z) anddateModified ≥ datePublished. - 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)
// 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 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 @ids, plus profilePage:
// 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:
// 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 geodoes): JSON parses;@types exist in schema.org; the required props from §3 are present;@idreferences 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