This guide is for the code-curious who want the markup and the generation behind a local presence, not listing tips. We will write a complete LocalBusiness JSON-LD block, add the geo and openingHoursSpecification properties validators ask for, express areaServed correctly, keep NAP consistent as data, and generate one correct block per location from a dataset. For the strategy and risk side, see our local strategy and local mastery guides.
This is the machine-readable version of your listing. Note the nested address, geo and hours — these are the parts most blocks get wrong or omit:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": "https://example.com/#localbusiness",
"name": "Example Plumbing",
"url": "https://example.com/",
"telephone": "+44-1246-000000",
"image": "https://example.com/shopfront.jpg",
"priceRange": "££",
"address": {
"@type": "PostalAddress",
"streetAddress": "1 Bridge Street",
"addressLocality": "Chesterfield",
"addressRegion": "Derbyshire",
"postalCode": "S42 6BT",
"addressCountry": "GB"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 53.235,
"longitude": -1.421
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
"opens": "08:00",
"closes": "18:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Saturday",
"opens": "09:00",
"closes": "13:00"
}
]
}
</script>
Use your real coordinates (read them off the map pin for the exact address) and your real hours. Both are factual claims about the business — never invent them, and never fake an aggregateRating to clear a warning; fabricated ratings are a manual-action risk.
geo is a GeoCoordinates object with decimal latitude and longitude. openingHoursSpecification is an array of objects, each listing one or more days and an opens/closes time in 24-hour HH:MM. Group days that share hours into one entry and split out the ones that differ (Saturday above). For a location closed on a day, simply omit it. For 24-hour operation use "opens": "00:00", "closes": "23:59" on the relevant days.
If you serve an area rather than only walk-ins, declare it with areaServed — a named place, or several:
"areaServed": [
{ "@type": "City", "name": "Chesterfield" },
{ "@type": "City", "name": "Dronfield" },
{ "@type": "AdministrativeArea", "name": "Derbyshire" }
]
This complements, but does not replace, having a real page per service area. The schema asserts the relationship; the page gives Google something to rank.
Name, address and phone must match exactly across your site, your schema and your off-site profiles, because mismatches weaken entity resolution. Treat the canonical values as a single source of truth in your codebase and render every surface from it, rather than hand-copying into templates where they drift:
// nap.js — one source of truth
export const NAP = {
name: "Example Plumbing",
telephone: "+44-1246-000000",
street: "1 Bridge Street",
locality: "Chesterfield",
region: "Derbyshire",
postcode: "S42 6BT",
country: "GB"
};
Now your visible footer, your LocalBusiness schema and your contact page all import NAP — change it once, it changes everywhere, and nothing drifts out of sync.
For multi-location sites, hand-typing blocks does not scale and guarantees inconsistency. Drive them from a dataset:
const locations = [
{ id: "chesterfield", name: "Example Plumbing — Chesterfield",
lat: 53.235, lng: -1.421, street: "1 Bridge Street",
locality: "Chesterfield", postcode: "S42 6BT", tel: "+44-1246-000000" },
// ...more locations
];
function localBusiness(loc) {
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": `https://example.com/${loc.id}#localbusiness`,
"name": loc.name,
"telephone": loc.tel,
"address": {
"@type": "PostalAddress",
"streetAddress": loc.street,
"addressLocality": loc.locality,
"postalCode": loc.postcode,
"addressCountry": "GB"
},
"geo": { "@type": "GeoCoordinates", "latitude": loc.lat, "longitude": loc.lng }
};
}
// render on each location page:
const json = JSON.stringify(localBusiness(loc));
Each location page emits its own block with a unique @id tied to its URL. Add openingHoursSpecification per location from the same record. The rule from the schema coder guide applies: every value comes from real data, and optional fields like ratings are guarded behind a check so a location with no reviews emits none rather than a fake one.
Parse every generated block in CI so malformed JSON fails the build, and run a sample of location pages through the Schema Debugger when the template changes. Check the on-site local signals with the Local SEO Checker and generate a starting block with the AI Schema Generator.
A coder maintains a forty-branch site where each location page was hand-built with copy-pasted schema, half of it missing geo and several with stale phone numbers. They move NAP and per-location data into a single dataset, write one localBusiness() generator, and render each page’s block from its record with a unique @id, real coordinates and per-branch hours. A CI step parses every block; a Schema Debugger pass on a sample confirms geo and hours now resolve. The inconsistency is gone, the validator warnings clear, and adding a branch is one row in the dataset instead of a new hand-edited block.
Omitting geo and openingHoursSpecification (the usual validator warnings). Faking ratings or hours to clear them — a manual-action risk. Copy-pasting NAP into templates so it drifts; keep one source of truth. Reusing the same @id across locations, or hand-typing blocks that fall out of sync. Declaring areaServed with no real page behind it. And shipping generated blocks with no validation gate.
Add a geo object of type GeoCoordinates with decimal latitude and longitude read from the exact address pin. Use real coordinates, never approximations you have not checked.
Use an openingHoursSpecification array; each entry lists one or more dayOfWeek values and opens/closes in 24-hour HH:MM. Group days that share hours, split out those that differ, omit closed days.
It declares the geographic area a business serves, as one or more named places. Use it for service-area businesses, alongside — not instead of — a real page per area.
Keep the canonical name, address and phone in one place in your codebase and render the footer, schema and contact page from it, so a change propagates everywhere and nothing drifts.
Drive it from a dataset: one generator function returning a LocalBusiness object built from each location record, emitting a unique @id per page with real coordinates and per-location hours.
No. Both are factual claims; invented hours mislead users and fabricated ratings risk a manual action. Omit optional properties unless you have genuine data.