My default structured data bundle
I got tired of treating Schema.org like a research project.
You know the drill. New client. New niche. Too many schema types. Then you add nothing because it feels endless.
So I stopped doing that. I built a small default bundle instead. A set of JSON-LD blocks I ship on almost every marketing or SaaS site, plus a couple of variants for blogs and local businesses.
This is the technical walkthrough of that bundle. No theory. Just the markup I actually use, and where it lives.
Core rules I follow
Before the snippets, a few constraints I stick to.
- JSON-LD only. No microdata, no RDFa. It lives in
<script type="application/ld+json">blocks. - One primary entity per page. Google can handle more, but I keep it simple unless I really need composites.
- Generated, not hand-written. I wire this into the build step or CMS fields. No manual copy paste for every page.
- Data must exist visually. If the user cannot see it, I do not put it in schema. That keeps me out of spam territory.
Where I inject JSON-LD
I put JSON-LD in the HTML head wherever possible.
React / Next.js: I attach it to a <Head> component. Astro / Svelte / plain HTML: I write it straight into <head>.
If I need dynamic data, I render the JSON on the server, then drop it in as stringified content.
<script type="application/ld+json">
{ ...jsonHere }
</script>
No CDNs. No external scripts. Search engines want the JSON inline.
1. Site-wide: WebSite + SearchAction
Every multi-page site I ship gets a base WebSite schema on the homepage. It is tiny, and Google uses it for sitelinks search boxes.
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Acme Analytics",
"url": "https://acmeanalytics.com/",
"potentialAction": {
"@type": "SearchAction",
"target": "https://acmeanalytics.com/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
A few rules I stick to:
- Only on the root URL. I keep this script on
/, not on every page. - Search endpoint must exist. If there is no search, I drop
potentialAction. No fake endpoints. - Exact canonical URL. I match the
urlvalue to the canonical link tag, every time.
Implementation detail. I have a small buildWebsiteSchema(config) helper where I feed siteName, url, and optional searchUrl. The page component just calls it and stringifies the JSON.
2. The main entity: Organization or LocalBusiness
Almost every client site represents a company of some sort. I start with a generic Organization. For brick-and-mortar clients I switch to a more specific LocalBusiness subtype.
Global company: Organization schema
This is my baseline for SaaS, agencies without public shops, and any online-only service.
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Acme Analytics",
"url": "https://acmeanalytics.com/",
"logo": "https://acmeanalytics.com/assets/logo.svg",
"sameAs": [
"https://twitter.com/acmeanalytics",
"https://www.linkedin.com/company/acme-analytics/"
],
"contactPoint": [
{
"@type": "ContactPoint",
"telephone": "+31-20-123-4567",
"contactType": "sales",
"areaServed": "NL",
"availableLanguage": ["en", "nl"]
}
]
}
Some details I do not skip:
- Logo URL. I use the same logo as in the header, absolute URL, and make sure it is crawlable.
- sameAs. Only real social profiles, nothing else. I do not put random directories in there.
- contactPoint. If we show a phone number on the site, I reflect it here. Otherwise I remove the block.
This usually lives on the homepage next to the WebSite schema. Two separate scripts.
Local business: LocalBusiness schema
For gyms, clinics, restaurants, barber shops, I use the matching LocalBusiness subtype instead: Restaurant, Physiotherapy, HealthClub, and so on.
Example for a gym:
{
"@context": "https://schema.org",
"@type": "HealthClub",
"name": "Lemon Performance Lab",
"image": "https://lemonperformance.nl/og-image.jpg",
"@id": "https://lemonperformance.nl/#business",
"url": "https://lemonperformance.nl/",
"telephone": "+31-6-1234-5678",
"address": {
"@type": "PostalAddress",
"streetAddress": "Brouwersgracht 100",
"addressLocality": "Amsterdam",
"postalCode": "1013 GP",
"addressCountry": "NL"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 52.381,
"longitude": 4.887
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Wednesday", "Friday"],
"opens": "07:00",
"closes": "18:00"
}
],
"sameAs": [
"https://www.instagram.com/lemonperformance/"
]
}
Things that matter here:
- I add an
@idwith a hash fragment. It gives the entity a stable identifier I can reference later if needed. - I do not invent coordinates. I pull them from Google Maps or the client.
openingHoursSpecificationmust match the actual hours displayed on the page.
On local sites this block is non-negotiable. Rankings and knowledge panel consistency improve a lot once it is in place.
3. Every page: WebPage schema
Most devs skip WebPage. I like it because it gives me a predictable way to describe the actual page entity, tie it to the main organization, and reuse metadata I already have.
I generate this on every indexable page with a simple helper.
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Customer data without the headaches",
"url": "https://acmeanalytics.com/customer-data-platform",
"description": "Acme Analytics gives you a privacy-first CDP your team can actually maintain.",
"inLanguage": "en",
"isPartOf": {
"@type": "WebSite",
"url": "https://acmeanalytics.com/"
},
"about": {
"@id": "https://acmeanalytics.com/#organization"
}
}
A couple of notes:
- name is almost always the page title.
- description mirrors the meta description or a trimmed hero copy.
- about points to the
@idof the organization on the homepage.
On static site generators this fits nicely into a layout template. I already have title, description, url variables, so I just feed them into buildWebPageSchema().
4. Blog posts: Article schema
Any site that has a blog or resources section gets Article markup on each post. I do not go crazy with subtypes unless I have a good reason. BlogPosting is enough.
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "How I shipped our analytics migration in 3 weeks",
"description": "The exact process I used to move 12 properties from UA to GA4 without losing our minds.",
"image": [
"https://acmeanalytics.com/blog/ga4-migration/cover.jpg"
],
"author": {
"@type": "Person",
"name": "Richard Lemon",
"url": "https://richardlemon.com/"
},
"publisher": {
"@type": "Organization",
"name": "Acme Analytics",
"logo": {
"@type": "ImageObject",
"url": "https://acmeanalytics.com/assets/logo-512.png"
}
},
"datePublished": "2024-03-18T09:00:00+01:00",
"dateModified": "2024-03-20T10:30:00+01:00",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://acmeanalytics.com/blog/ga4-migration"
}
}
This is where text data from the CMS pays off. I map fields directly:
- Title field to
headline. - Excerpt field to
description. - Featured image to
image. - Author model to
authorobject. - Published / updated timestamps to
datePublishedanddateModified.
If the blog supports multiple authors, I let author be an array. Same structure, just wrapped.
5. Product or pricing pages: Product + Offer
I am careful with Product schema. I only use it if there is an actual product with pricing and a way to buy. That could be an ecommerce product, a SaaS plan, or a course.
Here is a simple SaaS plan example on a pricing page.
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Acme Analytics Pro",
"description": "Event-based analytics for product teams that need real-time dashboards.",
"image": "https://acmeanalytics.com/assets/pro-plan.png",
"brand": {
"@type": "Organization",
"name": "Acme Analytics"
},
"offers": {
"@type": "Offer",
"url": "https://acmeanalytics.com/pricing",
"priceCurrency": "EUR",
"price": "79",
"priceValidUntil": "2025-12-31",
"availability": "https://schema.org/InStock"
}
}
Key constraints I follow:
- Exact price. It has to match the visible price on the page. If we show “starting at 79”, I use that number.
- No fake discounts. If the site does not show a discount, I do not use
priceSpecificationwithpricevspriceBeforeDiscount. - Limit the scope. I only put this schema on the pricing page or the dedicated product detail page, not globally.
For shops with reviews, I also wire in aggregateRating and review only when the rating count and values are rendered in the UI.
6. BreadcrumbList for content depth
If the site has a clear content hierarchy, I add BreadcrumbList. This can produce breadcrumb rich results, but I mostly like it because it encodes structure explicitly.
Example for a blog post nested one level deep.
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Blog",
"item": "https://acmeanalytics.com/blog"
},
{
"@type": "ListItem",
"position": 2,
"name": "GA4 Migration",
"item": "https://acmeanalytics.com/blog/ga4-migration"
}
]
}
I let the router or CMS build this automatically from the URL structure. If there is no real breadcrumb UI, I skip this. I like my structured data to mirror the layout.
7. How I actually wire this up
All of this is useless if it lives in a Notion doc. The power comes from making it boring, repeatable, and hard to break.
This is roughly how I integrate it on client projects.
Step 1: Central schema helpers
I keep a schema/ or seo/ folder with tiny pure functions that return POJOs for each type.
// schema/website.ts
export function buildWebsiteSchema({ name, url, searchUrl }) {
const base: any = {
"@context": "https://schema.org",
"@type": "WebSite",
name,
url
};
if (searchUrl) {
base.potentialAction = {
"@type": "SearchAction",
target: `${searchUrl}?q={search_term_string}`,
"query-input": "required name=search_term_string"
};
}
return base;
}
Each helper hides the annoying details. Pages just pass real data.
Step 2: Shared head component
I do not scatter <script> tags everywhere. Instead I use a shared SEO or head component that accepts structured data as an array.
function JsonLd({ data }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
function SeoHead({ title, description, schemas = [] }) {
return (
<Head>
<title>{title}</title>
<meta name="description" content={description} />
{schemas.map((schema, i) => (
<JsonLd key={i} data={schema} />
))}
</Head>
);
}
Now any page can plug in the relevant schema variants without repeating the script wrapper logic.
Step 3: Validate in CI at least once
I do two passes.
- During development I use the Schema.org or Google Rich Results test against my local or a staging URL.
- Before launch I run a quick automated check that hits a couple of canonical pages and asserts the
<script type="application/ld+json">blocks exist.
I am not chasing zero warnings. I mostly want to avoid accidentally shipping broken JSON or wildly inconsistent data.
The minimal bundle I recommend
If you build sites for clients and want a minimum structured data set that covers most use cases, I would ship this on every project:
- Homepage:
WebSite+OrganizationorLocalBusiness+WebPage. - Standard pages:
WebPage. - Blog index:
WebPage(optionallyCollectionPageif you want, but I do not bother). - Blog posts:
WebPage+BlogPosting+BreadcrumbList. - Pricing / product pages:
WebPage+Product(withOffer) when there is a real product.
This keeps the implementation small enough that you can wire it up properly, automate it, and forget about it. Which is the point. Structured data should be boring infra, not a one-off SEO stunt.
Ship it once, wire it into your layouts, and your future client projects get it for free.
Subscribe to my newsletter to get the latest updates and news
Member discussion