Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 8 min read

LocalBusiness JSON-LD for Service-Area Businesses (Copy-Paste Examples)

Most LocalBusiness structured data examples assume a storefront: a street address, a map pin, opening hours for walk-in customers. A lot of small businesses don't work that way. Plumbers, roofers, pest control companies,

Most LocalBusiness structured data examples assume a storefront: a street address, a map pin, opening hours for walk-in customers. A lot of small businesses don't work that way. Plumbers, roofers, pest control companies, mobile detailers and cleaning services drive to the customer. Many of them don't want their home address on the web at all.

This post gives you copy-paste JSON-LD for that kind of business (a service-area business), explains which properties Google actually uses, and shows how to add it to WordPress or a plain HTML/JS site without creating duplicate entities.

All examples use a made-up company, example.com, and fictional 555-01xx phone numbers. Replace them with real values that match what is visible on the page.

What Google requires (and what it ignores)

Google's LocalBusiness structured data docs list only two required properties:

  • name
  • address (a PostalAddress)

Everything else (telephone, url, openingHoursSpecification, geo, priceRange, department, etc.) is recommended. A few properties you will see in tutorials, such as areaServed, are valid schema.org but are not in Google's list of supported LocalBusiness properties. They are still useful for other consumers of your markup and for describing the business accurately. Just don't expect them to change how Google displays you.

The awkward part for a service-area business is that address is required while the business may not want to publish a street.

The address problem

You have three honest options:

  1. The business has a public office (even a small one customers can visit): use the full address.
  2. The business has no public location: publish the parts you are willing to show, usually city, state, ZIP and country, and leave out streetAddress.
  3. Never invent an address, use a virtual office you can't actually use, or copy a coworking address you don't rent. Structured data has to describe what is true and what is on the page.

Option 2 is valid schema.org. Google's docs say to "include as many properties as possible", so a partial address may limit how much Google can do with it, but it doesn't misrepresent the business. Whatever you put in the markup should match the address shown in the page's footer or contact section.

Example 1: minimal, no street address

Start here. It's small enough to read and covers the required properties.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Plumber",
  "@id": "https://example.com/#business",
  "name": "Example Plumbing Co.",
  "url": "https://example.com/",
  "telephone": "+1-954-555-0142",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "Fort Lauderdale",
    "addressRegion": "FL",
    "postalCode": "33301",
    "addressCountry": "US"
  }
}
</script>

Notes:

  • Use the most specific @type that fits. Plumber, Electrician, RoofingContractor, HVACBusiness, Locksmith, MovingCompany, HousePainter and GeneralContractor are all LocalBusiness subtypes. If nothing fits, use LocalBusiness.
  • @id is just a stable identifier (a URL plus a fragment). It is not a page. It lets other blocks on the site point to the same entity instead of creating a second business.
  • Use the same phone number format everywhere on the page. +1-954-555-0142 and (954) 555-0142 are the same number to people, but consistency makes your own checks easier.

Example 2: a fuller service-area business

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Plumber",
  "@id": "https://example.com/#business",
  "name": "Example Plumbing Co.",
  "url": "https://example.com/",
  "logo": "https://example.com/wp-content/uploads/logo-512.png",
  "image": [
    "https://example.com/wp-content/uploads/van-1x1.jpg",
    "https://example.com/wp-content/uploads/van-4x3.jpg",
    "https://example.com/wp-content/uploads/van-16x9.jpg"
  ],
  "telephone": "+1-954-555-0142",
  "email": "[email protected]",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "Fort Lauderdale",
    "addressRegion": "FL",
    "postalCode": "33301",
    "addressCountry": "US"
  },
  "areaServed": [
    { "@type": "City", "name": "Fort Lauderdale" },
    { "@type": "City", "name": "Hollywood" },
    { "@type": "City", "name": "Pompano Beach" },
    { "@type": "AdministrativeArea", "name": "Broward County" }
  ],
  "openingHoursSpecification": [
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
      "opens": "07:00",
      "closes": "18:00"
    },
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": "Saturday",
      "opens": "08:00",
      "closes": "14:00"
    }
  ],
  "priceRange": "$$",
  "sameAs": [
    "https://www.facebook.com/exampleplumbingco",
    "https://www.linkedin.com/company/exampleplumbingco"
  ]
}
</script>

What each addition is for:

  • areaServed: where the business actually works. Use real places. Listing 60 cities you never drive to doesn't help anyone and makes the markup look like keyword stuffing.
  • openingHoursSpecification: the hours the phone is answered or jobs are booked. Leave it out if you can't keep it accurate.
  • sameAs: profiles that are clearly the same business. Only include ones you control.
  • image: Google's docs recommend several aspect ratios (1x1, 4x3, 16x9). Images must be crawlable and represent the business.

Variant: radius instead of a city list

If the business really works "within about 25 miles of the shop", a GeoCircle says that directly. geoRadius is in meters when it's a plain number.

"areaServed": {
  "@type": "GeoCircle",
  "geoMidpoint": {
    "@type": "GeoCoordinates",
    "latitude": 26.1224,
    "longitude": -80.1373
  },
  "geoRadius": "40000"
}

A midpoint is a point, not an address, but if the business operates from a home, round the coordinates to a city center rather than its exact location.

Variant: open 24/7 (emergency services)

Google's docs use 00:00 to 23:59 for a day that is open all 24 hours:

"openingHoursSpecification": {
  "@type": "OpeningHoursSpecification",
  "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
  "opens": "00:00",
  "closes": "23:59"
}

Variant: more than one trade

A company that does both electrical and HVAC work can use an array of types:

"@type": ["Electrician", "HVACBusiness"]

Example 3: tie it into one @graph

Many sites output a WebSite and Organization block from an SEO plugin, then someone pastes a separate LocalBusiness block, and now the page describes two or three unrelated entities. Put them in one graph and link them with @id:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebSite",
      "@id": "https://example.com/#website",
      "url": "https://example.com/",
      "name": "Example Plumbing Co.",
      "publisher": { "@id": "https://example.com/#business" }
    },
    {
      "@type": "Plumber",
      "@id": "https://example.com/#business",
      "name": "Example Plumbing Co.",
      "url": "https://example.com/",
      "telephone": "+1-954-555-0142",
      "address": {
        "@type": "PostalAddress",
        "addressLocality": "Fort Lauderdale",
        "addressRegion": "FL",
        "postalCode": "33301",
        "addressCountry": "US"
      },
      "areaServed": { "@type": "AdministrativeArea", "name": "Broward County" }
    },
    {
      "@type": "WebPage",
      "@id": "https://example.com/water-heater-repair/#webpage",
      "url": "https://example.com/water-heater-repair/",
      "name": "Water Heater Repair",
      "isPartOf": { "@id": "https://example.com/#website" },
      "about": { "@id": "https://example.com/#business" }
    }
  ]
}
</script>

The service page doesn't redefine the business. It references #business by @id.

Service pages and "city pages"

A common mistake on small-business sites is putting a full LocalBusiness block on every city landing page, each with a different city in address. That describes businesses that don't exist.

A safer pattern:

  • One LocalBusiness entity, defined once (usually on the home page or site-wide), with the real address.
  • Service pages and city pages reference it with about or provider (as in Example 3) and describe the service, not a new location.
  • If the company genuinely has more than one staffed location, each location gets its own entity, its own @id, its own page, and its own real address.

For a service page, a Service node can describe the work and point back to the business:

{
  "@type": "Service",
  "@id": "https://example.com/water-heater-repair/#service",
  "serviceType": "Water heater repair",
  "provider": { "@id": "https://example.com/#business" },
  "areaServed": { "@type": "City", "name": "Hollywood" }
}

Things to leave out

  • aggregateRating / review about yourself. Google's docs say aggregateRating on LocalBusiness is only recommended for sites that capture reviews about other local businesses. Self-serving review markup on your own business page isn't eligible for review stars.
  • Anything not visible on the page. If the hours, phone or service area are only in the JSON-LD, either add them to the page or remove them from the markup.
  • Copy-pasted plugin defaults. Check that a theme or SEO plugin isn't already outputting a second LocalBusiness with old data (an old phone number or business name is common).

Adding it in WordPress

If your SEO plugin has a Local SEO module, use that so you don't end up with two blocks. If you're adding it by hand, a small snippet (child theme functions.php or a code-snippets plugin) keeps it in one place:

add_action( 'wp_head', function () {
    if ( ! is_front_page() ) {
        return; // define the business once; other pages reference the @id
    }

    $business = array(
        '@context'  => 'https://schema.org',
        '@type'     => 'Plumber',
        '@id'       => home_url( '/#business' ),
        'name'      => 'Example Plumbing Co.',
        'url'       => home_url( '/' ),
        'telephone' => '+1-954-555-0142',
        'address'   => array(
            '@type'           => 'PostalAddress',
            'addressLocality' => 'Fort Lauderdale',
            'addressRegion'   => 'FL',
            'postalCode'      => '33301',
            'addressCountry'  => 'US',
        ),
        'areaServed' => array(
            '@type' => 'AdministrativeArea',
            'name'  => 'Broward County',
        ),
    );

    echo '<script type="application/ld+json">'
        . wp_json_encode( $business, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
        . '</script>' . "\n";
}, 20 );

wp_json_encode() handles escaping, so you don't hand-build JSON strings in PHP.

Adding it on a JS-rendered site

Google can read JSON-LD that's injected with JavaScript, but server-rendering it is simpler to debug. In a React/Next.js page:

export default function Home() {
  const business = {
    "@context": "https://schema.org",
    "@type": "Plumber",
    "@id": "https://example.com/#business",
    name: "Example Plumbing Co.",
    url: "https://example.com/",
    telephone: "+1-954-555-0142",
    address: {
      "@type": "PostalAddress",
      addressLocality: "Fort Lauderdale",
      addressRegion: "FL",
      postalCode: "33301",
      addressCountry: "US",
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(business) }}
      />
      {/* page content */}
    </>
  );
}

If any value comes from user input or a CMS, escape < (for example .replace(/</g, "\\u003c")) before injecting, so a stray </script> can't break out of the tag.

Check what's already on the page

Before adding anything, see what types the page already outputs:

curl -s https://example.com/ \
  | grep -o '"@type":"[^"]*"' \
  | sort | uniq -c

Two LocalBusiness-family types on the same page is usually a sign that a plugin and a hand-written block are both running.

Then validate:

  1. Schema Markup Validator (validator.schema.org) for general schema.org correctness.
  2. Rich Results Test (search.google.com/test/rich-results) for what Google can read. A missing streetAddress may show as a warning; that's expected for Example 1.
  3. After deploying, URL Inspection in Search Console to confirm Google sees the rendered markup.

Where this fits

Structured data is one item on a normal on-page list, next to titles, headings, internal links and image alt text. If you want the rest of that list for a small-business site, I keep an on-page SEO checklist that covers the other items in plain language.

Markup doesn't replace a clear page. If a person can't tell from the page who you are, where you work and how to reach you, JSON-LD won't fix that. It just makes what's already true easier for machines to read.

Copy-paste checklist

  • [ ] One LocalBusiness entity with a stable @id
  • [ ] Most specific @type that fits
  • [ ] name and address present; no invented street
  • [ ] Phone, hours and service area match what's visible on the page
  • [ ] areaServed lists real places (or a real radius)
  • [ ] No self-serving aggregateRating
  • [ ] Service and city pages reference #business instead of redefining it
  • [ ] No duplicate LocalBusiness from a plugin
  • [ ] Passes the Schema Markup Validator and the Rich Results Test

This article was drafted with the help of AI and reviewed and tested by the author.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.