Skip to content

Reference · seo

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

seo/references/structured-data.md199 linesupdated 16 Sept 2026

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 @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.
  5. Escape < in the serialized JSON. Use a native 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:

    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; @types 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