Local Business
On this page. SportingGoodsStore with address, geo, phone and opening hours.
One physical store of a brand with several locations: the most specific LocalBusiness type with address, map position and hours, linked to the parent Organization from the homepage.
Use on the page for one physical location. A brand with several stores gets one page, and one LocalBusiness, per store.
Each location links to the brand with parentOrganization, pointing at the Organization @id from the homepage, so Google sees one brand with many places instead of many unrelated businesses.
Each card opens the framework for that feature, with its own rules and property reference. Cards marked "Linked by @id" are defined on the homepage and only referenced here.
Every node has a stable @id. A node is written out once, where it belongs, and everywhere else it is referenced by that @id alone. This is the map for this page, generated from the output below.
| @id | Defined on | Type | Referenced by |
|---|---|---|---|
/stores/portland/#webpage | This page | WebPage | Top-level node |
/stores/portland/#breadcrumb | This page | BreadcrumbList | WebPage.breadcrumb |
/stores/portland/#location | This page | SportingGoodsStore | WebPage.about |
/#organization | Homepage | OnlineStore | SportingGoodsStore.parentOrganization |
/#website | Homepage | WebSite | WebPage.isPartOf |
One function turns the page's data into the whole graph. It drops anything Google would reject instead of shipping it half built. The input below includes rows it is meant to drop, so you can see the logic work.
// Local business location page schema: one physical location of a brand.
// The most specific LocalBusiness subtype with address, geo and hours,
// linked to the parent Organization from the homepage with
// parentOrganization, plus the page's WebPage and BreadcrumbList.
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
const COUNTRY = /^[A-Z]{2}$/;
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const clean = (v) => (typeof v === 'string' ? v.trim() : v);
const isUrl = (v) => typeof v === 'string' && /^https:\/\/[^\s]+$/.test(v);
const compact = (obj) => {
if (Array.isArray(obj)) {
const out = obj.map(compact).filter((v) => v !== undefined);
return out.length ? out : undefined;
}
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const c = compact(v);
if (c !== undefined) out[k] = c;
}
return Object.keys(out).length ? out : undefined;
}
return obj === null || obj === '' ? undefined : obj;
};
function breadcrumb(trail, id) {
const items = (trail || []).filter((c) => c.name && isUrl(c.url));
if (items.length < 2) return undefined;
return {
'@type': 'BreadcrumbList',
'@id': id,
itemListElement: items.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(c.name), item: c.url })),
};
}
// Opening hours: days with the same open and close times are grouped into
// one specification and closed days are left out. A night open past midnight
// is split into an evening span and an early-hours span on the next day,
// rather than a close time that comes before the open time.
function openingHours(hours) {
const spans = DAYS.map(() => []);
DAYS.forEach((day, i) => {
const h = (hours || {})[day];
if (!h || !TIME.test(h.opens || '') || !TIME.test(h.closes || '') || h.opens === h.closes) return;
if (h.closes > h.opens) spans[i].push([h.opens, h.closes]);
else {
spans[i].push([h.opens, '23:59']);
spans[(i + 1) % 7].push(['00:00', h.closes]);
}
});
const groups = new Map();
DAYS.forEach((day, i) => {
for (const [opens, closes] of spans[i]) {
const key = `${opens}-${closes}`;
if (!groups.has(key)) groups.set(key, { opens, closes, days: [] });
groups.get(key).days.push(day);
}
});
return [...groups.values()].map((g) => ({ '@type': 'OpeningHoursSpecification', dayOfWeek: g.days, opens: g.opens, closes: g.closes }));
}
function buildLocalBusinessPageSchema(source) {
const site = source.site || {};
const page = source.page || {};
const loc = source.location || {};
const addr = loc.address || {};
if (!isUrl(site.url) || !isUrl(page.url) || !loc.name) return null;
// Google requires a full street address for a local business.
if (!addr.street || !addr.city || !COUNTRY.test(addr.country || '')) return null;
const root = site.url.replace(/\/+$/, '');
const pageUrl = page.url;
const businessId = `${pageUrl}#location`;
const geo = Number.isFinite(loc.lat) && Number.isFinite(loc.lng) && Math.abs(loc.lat) <= 90 && Math.abs(loc.lng) <= 180
? { '@type': 'GeoCoordinates', latitude: Number(loc.lat.toFixed(5)), longitude: Number(loc.lng.toFixed(5)) }
: undefined;
// No aggregateRating: Google treats ratings a business shows about itself
// on its own site as self-serving and never gives them review stars.
const business = compact({
'@type': loc.type || 'LocalBusiness',
'@id': businessId,
name: clean(loc.name),
url: pageUrl,
image: (loc.images || []).filter(isUrl),
telephone: clean(loc.telephone),
priceRange: clean(loc.priceRange),
address: {
'@type': 'PostalAddress',
streetAddress: clean(addr.street),
addressLocality: clean(addr.city),
addressRegion: clean(addr.region),
postalCode: clean(addr.postalCode),
addressCountry: addr.country,
},
geo,
openingHoursSpecification: openingHours(loc.hours),
servesCuisine: clean(loc.servesCuisine),
menu: isUrl(loc.menu) ? loc.menu : undefined,
acceptsReservations: typeof loc.acceptsReservations === 'boolean' ? loc.acceptsReservations : undefined,
parentOrganization: { '@id': `${root}/#organization` },
});
const crumbs = breadcrumb(page.breadcrumbs, `${pageUrl}#breadcrumb`);
const webpage = compact({
'@type': 'WebPage',
'@id': `${pageUrl}#webpage`,
url: pageUrl,
name: clean(page.title) || clean(loc.name),
isPartOf: { '@id': `${root}/#website` },
breadcrumb: crumbs ? { '@id': crumbs['@id'] } : undefined,
about: { '@id': businessId },
});
return { '@context': 'https://schema.org', '@graph': [webpage, crumbs, business].filter(Boolean) };
}
{
"site": {
"url": "https://www.example.com"
},
"page": {
"url": "https://www.example.com/stores/portland/",
"title": "Harbor & Pine Portland | Outdoor store on Commercial Street",
"breadcrumbs": [
{
"name": "Home",
"url": "https://www.example.com/"
},
{
"name": "Stores",
"url": "https://www.example.com/stores/"
},
{
"name": "Portland",
"url": "https://www.example.com/stores/portland/"
}
]
},
"location": {
"type": "SportingGoodsStore",
"name": "Harbor & Pine Portland",
"images": [
"https://www.example.com/img/stores/portland-1x1.jpg",
"https://www.example.com/img/stores/portland-4x3.jpg",
"https://www.example.com/img/stores/portland-16x9.jpg"
],
"telephone": "+1-207-555-0161",
"priceRange": "$$",
"address": {
"street": "214 Commercial Street",
"city": "Portland",
"region": "ME",
"postalCode": "04101",
"country": "US"
},
"lat": 43.656912,
"lng": -70.252518,
"hours": {
"Monday": {
"opens": "10:00",
"closes": "18:00"
},
"Tuesday": {
"opens": "10:00",
"closes": "18:00"
},
"Wednesday": {
"opens": "10:00",
"closes": "18:00"
},
"Thursday": {
"opens": "10:00",
"closes": "20:00"
},
"Friday": {
"opens": "10:00",
"closes": "20:00"
},
"Saturday": {
"opens": "09:00",
"closes": "20:00"
}
}
}
}
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://www.example.com/stores/portland/#webpage",
"url": "https://www.example.com/stores/portland/",
"name": "Harbor & Pine Portland | Outdoor store on Commercial Street",
"isPartOf": {
"@id": "https://www.example.com/#website"
},
"breadcrumb": {
"@id": "https://www.example.com/stores/portland/#breadcrumb"
},
"about": {
"@id": "https://www.example.com/stores/portland/#location"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://www.example.com/stores/portland/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.example.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Stores",
"item": "https://www.example.com/stores/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Portland",
"item": "https://www.example.com/stores/portland/"
}
]
},
{
"@type": "SportingGoodsStore",
"@id": "https://www.example.com/stores/portland/#location",
"name": "Harbor & Pine Portland",
"url": "https://www.example.com/stores/portland/",
"image": [
"https://www.example.com/img/stores/portland-1x1.jpg",
"https://www.example.com/img/stores/portland-4x3.jpg",
"https://www.example.com/img/stores/portland-16x9.jpg"
],
"telephone": "+1-207-555-0161",
"priceRange": "$$",
"address": {
"@type": "PostalAddress",
"streetAddress": "214 Commercial Street",
"addressLocality": "Portland",
"addressRegion": "ME",
"postalCode": "04101",
"addressCountry": "US"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 43.65691,
"longitude": -70.25252
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"Monday",
"Tuesday",
"Wednesday"
],
"opens": "10:00",
"closes": "18:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"Thursday",
"Friday"
],
"opens": "10:00",
"closes": "20:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"Saturday"
],
"opens": "09:00",
"closes": "20:00"
}
],
"parentOrganization": {
"@id": "https://www.example.com/#organization"
}
}
]
}
The output above was run through the SchemaCDN validator, which checks every rule in Google's documentation for each feature. It was validated together with the homepage graph, so the @id links resolve. Result: no errors, and eligible for:
The anchor every other page type points at: Organization, site name and store-wide policies.
ProductGroup with a priced Offer per variant, member pricing and links to store-wide policies.
Article with a real Person author, the Organization as publisher, video and a paywalled section.
Recipe with timed steps, nutrition, ratings and a video whose key moments match the steps.
One open role with pay, closing date and work location, linked to the employer and the store.
An in-store workshop with times, a rescheduled date, the store as venue and a free ticket.
A community thread that becomes Q&A or a discussion posting, depending on the thread.