Local SEO for the Code-Curious: LocalBusiness Schema & Code

For the Code-Curious LocalBusiness in code geo, opening hours, areaServed and per-location schema — generated, not hand-typed. Show me how →
Your journey cost
Tick the steps you want — total updates live
Total
Live prices · pay as you go
Pricing comparison
PAYG vs Subscription
PAYG
£0 /mo min

Top up from £4.99 · credits never expire

Subscription

Select a plan to compare.

£4.99/mo
Compare against plan:
Calculating…

Local SEO for the code-curious: LocalBusiness schema and code

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.

A complete LocalBusiness block

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 and openingHoursSpecification, precisely

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.

Express the area you serve

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.

NAP consistency is a data problem

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.

Generate one block per location

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.

Validate it

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 worked example

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.

Common mistakes to avoid

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.

Frequently asked questions

How do I add geo coordinates to LocalBusiness schema?

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.

How do I format opening hours in JSON-LD?

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.

What is areaServed and when do I use it?

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.

How do I keep NAP consistent across the site?

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.

How do I generate schema for many locations?

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.

Can I fake opening hours or ratings to clear warnings?

No. Both are factual claims; invented hours mislead users and fabricated ratings risk a manual action. Omit optional properties unless you have genuine data.