JSON-LD schema templates by site type
Most JSON-LD advice online is either a wall of schema.org spec pages or a single toy Person example that doesn't tell you how real entities connect. This is a copy-ready reference: 7 site categories, each with the schema types worth using, a full TypeScript template with the nodes actually linked via @id, where it goes in an App Router project, and the honest gotchas — including where a rich result is realistic and where it isn't.
If your site's structured data is already broken and you want to find out exactly where, that's what next-seo-doctor is for — it crawls your built site and flags dangling @id references, unescaped script breakouts, and invalid JSON-LD blocks directly. This page is what you paste in once the checks are clean.
Every category returns a graph — an array of @id-linked nodes — not isolated nodes with no relationship to each other. Render any of them with the same component:
// components/json-ld.tsx — shared by every template below
export function JsonLd({ data }: { data: object }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}That .replace(/</g, "\\u003c") isn't optional — without it, any title, description, or FAQ answer pulled from user input or a CMS can close your <script> tag early and inject markup.
Blog / content site
Which schemas to use. A site-wide Person (or Organization) and WebSite, then a BlogPosting per post whose author links back to the Person via @id, plus a BreadcrumbList on each post.
const SITE_URL = "https://YOUR_SITE_URL";
const personId = `${SITE_URL}/#person`;
const siteId = `${SITE_URL}/#website`;
export function blogPostSchema(post: {
slug: string;
title: string;
description: string;
datePublished: string;
dateModified: string;
}) {
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": personId,
name: "YOUR_NAME",
url: SITE_URL,
},
{
"@type": "WebSite",
"@id": siteId,
url: SITE_URL,
name: "YOUR_SITE_NAME",
publisher: { "@id": personId },
},
{
"@type": "BlogPosting",
"@id": `${SITE_URL}/blog/${post.slug}/#post`,
headline: post.title,
description: post.description,
datePublished: post.datePublished,
dateModified: post.dateModified,
author: { "@id": personId },
isPartOf: { "@id": siteId },
url: `${SITE_URL}/blog/${post.slug}`,
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: SITE_URL },
{ "@type": "ListItem", position: 2, name: "Blog", item: `${SITE_URL}/blog` },
{ "@type": "ListItem", position: 3, name: post.title, item: `${SITE_URL}/blog/${post.slug}` },
],
},
],
};
}Where it goes. Person + WebSite render once, site-wide (root layout or homepage). BlogPosting + BreadcrumbList render per post, in the post page.
When and when not.
- Don't invent a
datePublished/dateModifiedsplit if you don't track edits — use the same date for both rather than a fake modified date. authorshould be an@idreference to the samePersonnode everywhere, not a repeated inline object — that's what makes it a graph instead of confetti.
Rich-result reality check. BlogPosting can earn article rich results (byline, date) in some surfaces; the bigger win is Google reliably understanding who wrote what across your whole site, which compounds over many posts.
E-commerce / product store
Which schemas to use. A site-wide Organization, then a Product per product page with offers (Offer — price, currency, availability), AggregateRating only when real reviews exist, and a BreadcrumbList.
const SITE_URL = "https://YOUR_SITE_URL";
const orgId = `${SITE_URL}/#organization`;
export function productSchema(product: {
slug: string;
name: string;
description: string;
image: string;
price: string;
currency: string;
availability: "InStock" | "OutOfStock" | "PreOrder";
aggregateRating?: { ratingValue: number; reviewCount: number };
}) {
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": orgId,
name: "YOUR_STORE_NAME",
url: SITE_URL,
},
{
"@type": "Product",
"@id": `${SITE_URL}/products/${product.slug}/#product`,
name: product.name,
description: product.description,
image: product.image,
brand: { "@id": orgId },
offers: {
"@type": "Offer",
url: `${SITE_URL}/products/${product.slug}`,
price: product.price,
priceCurrency: product.currency,
availability: `https://schema.org/${product.availability}`,
seller: { "@id": orgId },
},
// Only include this if the reviews are real and visible on this
// page. Fabricated ratings are exactly what manual actions target.
...(product.aggregateRating && {
aggregateRating: {
"@type": "AggregateRating",
ratingValue: product.aggregateRating.ratingValue,
reviewCount: product.aggregateRating.reviewCount,
},
}),
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: SITE_URL },
{ "@type": "ListItem", position: 2, name: "Products", item: `${SITE_URL}/products` },
{ "@type": "ListItem", position: 3, name: product.name, item: `${SITE_URL}/products/${product.slug}` },
],
},
],
};
}Where it goes. Organization site-wide. Product + BreadcrumbList on each product page, built from the same data that renders the visible price and stock state.
When and when not.
- Never hardcode
availability: "InStock"— it must track the real inventory state shown on the page, or you're publishing a lie a crawler can catch instantly by comparing it to checkout. - Don't add
AggregateRatingfrom a handful of fake or seeded reviews. Google looks for this specifically and it's a fast way to earn a manual action. price/priceCurrencymust match what a buyer actually sees — no "starting at" price for a single-variant listing.
Rich-result reality check. Product + Offer can earn price/availability rich snippets directly in search results — one of the few categories here where the rich result is the whole point, not just entity clarity.
SaaS / software product
Which schemas to use. Organization plus SoftwareApplication (or WebApplication if there's no downloadable artifact) with offers for pricing, and FAQPage on the pricing/landing page only where a real, visible FAQ exists.
const SITE_URL = "https://YOUR_SITE_URL";
const orgId = `${SITE_URL}/#organization`;
export function saasSchema() {
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": orgId,
name: "YOUR_COMPANY_NAME",
url: SITE_URL,
},
{
"@type": "SoftwareApplication",
"@id": `${SITE_URL}/#software`,
name: "YOUR_PRODUCT_NAME",
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
author: { "@id": orgId },
offers: {
"@type": "Offer",
price: "29.00",
priceCurrency: "USD",
},
},
],
};
}
// Add this only if the FAQ text below is real, visible copy on the page —
// see the gotcha under "When and when not."
export function pricingFaqSchema(faq: { question: string; answer: string }[]) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faq.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
};
}Where it goes. Organization + SoftwareApplication site-wide or on the marketing homepage. FAQPage only on the specific page whose visible content matches it exactly.
When and when not.
FAQPagerich results are now largely restricted to well-known, authoritative sites — for most SaaS sites this buys you entity clarity, not a guaranteed rich result. Don't build a feature around the expectation of one.- The FAQ text in the schema must match what's visibly rendered on the page. Schema that says one thing while the page says another (or nothing) is exactly what structured-data spam enforcement targets.
operatingSystem: "Web"is honest for a browser-only SaaS — don't claim platforms you don't ship.
Rich-result reality check. SoftwareApplication mostly aids entity understanding and knowledge-panel-style disambiguation; treat any FAQPage rich result as a bonus, not the plan.
Local business
Which schemas to use. LocalBusiness or the closest schema.org subtype (e.g. Restaurant), with address, geo, openingHoursSpecification, and telephone. Add hasMenu only if a menu page actually exists.
const SITE_URL = "https://YOUR_SITE_URL";
export function localBusinessSchema() {
return {
"@context": "https://schema.org",
"@type": "Restaurant", // or the plain "LocalBusiness" type if no subtype fits
"@id": `${SITE_URL}/#business`,
name: "YOUR_BUSINESS_NAME",
url: SITE_URL,
telephone: "+1-555-555-5555",
address: {
"@type": "PostalAddress",
streetAddress: "123 Main St",
addressLocality: "YOUR_CITY",
addressRegion: "YOUR_REGION",
postalCode: "00000",
addressCountry: "US",
},
geo: {
"@type": "GeoCoordinates",
latitude: 0,
longitude: 0,
},
openingHoursSpecification: [
{
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
opens: "09:00",
closes: "18:00",
},
],
hasMenu: `${SITE_URL}/menu`, // remove this line entirely if there's no menu
};
}Where it goes. Site-wide — usually the root layout or the homepage, since there's typically one physical location per site in this category.
When and when not.
openingHoursSpecificationmust match reality closely enough that a customer showing up during "open" hours isn't turned away — this is one of the few schema types tied directly to a real-world consequence.- Pick the most specific subtype schema.org offers (
Restaurant,Dentist,Store, ...) over plainLocalBusinesswhen one fits — it unlocks more specific rich-result treatment. - Don't fabricate
geocoordinates — an approximate pin is worse than none for anything map-related.
Rich-result reality check. This category has some of the strongest, most reliable rich-result support in schema.org — Maps and local pack results lean directly on this data, not just on entity clarity.
Real estate listings
Which schemas to use. RealEstateListing describing an about (a Residence/Apartment) plus offers for price. This one has weaker rich-result support than the categories above it — treat it mostly as entity clarity.
const SITE_URL = "https://YOUR_SITE_URL";
export function listingSchema(listing: {
slug: string;
name: string;
description: string;
price: string;
currency: string;
streetAddress: string;
addressLocality: string;
addressRegion: string;
postalCode: string;
}) {
return {
"@context": "https://schema.org",
"@type": "RealEstateListing",
"@id": `${SITE_URL}/listings/${listing.slug}/#listing`,
url: `${SITE_URL}/listings/${listing.slug}`,
name: listing.name,
description: listing.description,
about: {
"@type": "Residence",
name: listing.name,
address: {
"@type": "PostalAddress",
streetAddress: listing.streetAddress,
addressLocality: listing.addressLocality,
addressRegion: listing.addressRegion,
postalCode: listing.postalCode,
addressCountry: "US",
},
},
offers: {
"@type": "Offer",
price: listing.price,
priceCurrency: listing.currency,
},
};
}Where it goes. Per listing page, built from whatever data already renders the listing's visible price and address.
When and when not.
- Don't expect a dedicated rich result the way
Productgets one — schema.org's real-estate coverage is thinner and Google's support for it is inconsistent. This is worth doing for clean entity data, not for a rich-result payoff. - Keep
pricecurrent — a stale listing schema for a sold property is worse than none. - If the listing has photos, a plain
imagearray on theRealEstateListingnode is safe to add; don't reach for exotic real-estate-specific properties that aren't well documented.
Rich-result reality check. Mostly entity disambiguation — don't build this expecting a price/availability snippet like e-commerce gets.
Portfolio / personal site
Which schemas to use. Person as the root entity with sameAs linking your GitHub/LinkedIn, a WebSite, and SoftwareSourceCode (or CreativeWork for non-code work) per project.
const SITE_URL = "https://YOUR_SITE_URL";
const personId = `${SITE_URL}/#person`;
export function portfolioSchema(project?: {
slug: string;
name: string;
description: string;
repoUrl: string;
}) {
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": personId,
name: "YOUR_NAME",
url: SITE_URL,
sameAs: [
"https://github.com/YOUR_USERNAME",
"https://linkedin.com/in/YOUR_USERNAME",
],
},
{
"@type": "WebSite",
"@id": `${SITE_URL}/#website`,
url: SITE_URL,
name: "YOUR_NAME",
publisher: { "@id": personId },
},
...(project
? [
{
"@type": "SoftwareSourceCode",
"@id": `${SITE_URL}/projects/${project.slug}/#project`,
name: project.name,
description: project.description,
author: { "@id": personId },
codeRepository: project.repoUrl,
},
]
: []),
],
};
}Where it goes. Person + WebSite site-wide. SoftwareSourceCode/CreativeWork per project page.
When and when not.
sameAsshould only list profiles you actually control and update — a dead or abandoned linked profile is a small but real trust signal loss.- Don't add
Person.jobTitle/worksForspeculatively; only include fields you're prepared to keep current, since stale schema is worse than sparse schema.
Rich-result reality check. Almost entirely entity clarity — this helps Google understand "this site is the canonical home for this person," which matters for name-search disambiguation, not for a rich snippet.
Docs / knowledge base
Which schemas to use. A site-wide WebSite, then TechArticle per page with a BreadcrumbList reflecting the docs hierarchy (section → page).
const SITE_URL = "https://YOUR_SITE_URL";
const siteId = `${SITE_URL}/#website`;
export function docsPageSchema(doc: {
slug: string;
title: string;
description: string;
dateModified: string;
section: string;
}) {
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": siteId,
url: SITE_URL,
name: "YOUR_DOCS_SITE_NAME",
},
{
"@type": "TechArticle",
"@id": `${SITE_URL}/docs/${doc.slug}/#article`,
headline: doc.title,
description: doc.description,
dateModified: doc.dateModified,
isPartOf: { "@id": siteId },
url: `${SITE_URL}/docs/${doc.slug}`,
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: SITE_URL },
{ "@type": "ListItem", position: 2, name: "Docs", item: `${SITE_URL}/docs` },
{ "@type": "ListItem", position: 3, name: doc.section, item: `${SITE_URL}/docs/${doc.slug}` },
],
},
],
};
}Where it goes. WebSite site-wide. TechArticle + BreadcrumbList per docs page, driven by the same frontmatter/CMS fields that render the visible title and section.
When and when not.
- Use the real
dateModifiedfrom your content source (frontmatter, CMS, git history) — faking it is the JSON-LD version of a sitemap that lies aboutlastModified. - Don't reach for
HowToschema just because a docs page has numbered steps — Google has scaled backHowTorich results significantly;TechArticleis the safer, well-supported choice.
Rich-result reality check. Mostly entity clarity and breadcrumb display in search results; don't expect a distinct rich snippet from TechArticle alone.
Built alongside next-seo-doctor — run it against your built site to check whether your own graph actually holds together. More on the reasoning behind all this in Technical SEO in Next.js.