DEV Community

Cover image for The JSON-LD Playbook for Local Business Sites (and the mistake that quietly tanks it)
Randy Rodriguez
Randy Rodriguez

Posted on

The JSON-LD Playbook for Local Business Sites (and the mistake that quietly tanks it)

Structured data is how a search engine (and now an AI answer engine) understands what your site "is", not just what words it contains. Done right, it unlocks rich results, knowledge panels, and citations in ChatGPT and Perplexity. Done wrong, it silently sends conflicting signals that cancel each other out.

I recently shipped a local service site and spent real time getting the schema correct. Here's the playbook, plus the one mistake I see on almost every local site.

Where JSON-LD lives

JSON-LD is just a <script type="application/ld+json"> block. Framework-agnostic. In React/Next you can render it with a tiny component:

function JsonLd({ data }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

That's the whole mechanism. Everything else is getting the object right.

The core: one business node in an @graph

Use a specific LocalBusiness subtype when one exists (HVACBusiness, Dentist, Plumber, Restaurant), and give it a stable @id so other nodes can reference it. Wrapping everything in an @graph lets you ship multiple linked nodes in a single script:

const graph = {
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebSite",
      "@id": "https://example.com/#website",
      url: "https://example.com",
      publisher: { "@id": "https://example.com/#business" },
    },
    {
      "@type": "HVACBusiness",
      "@id": "https://example.com/#business",
      name: "Broward AC Guys",
      telephone: "+17868371512",
      priceRange: "$$",
      areaServed: [
        { "@type": "AdministrativeArea", name: "Broward County, FL" },
        { "@type": "AdministrativeArea", name: "Miami-Dade County, FL" },
      ],
      openingHoursSpecification: {
        "@type": "OpeningHoursSpecification",
        dayOfWeek: ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"],
        opens: "00:00",
        closes: "23:59",
      },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

The @id is the important part. It turns loose objects into a connected graph a crawler can reason about.

The mistake: a LocalBusiness on every location page

Here's the one that quietly hurts you. Local sites often have a page per city (/service-areas/miami, /service-areas/hollywood, ...) and drop a full LocalBusiness block on each, changing only the city name.

You just told Google you have a dozen businesses that share one name and one phone number. That's a NAP (name/address/phone) consistency problem, and it dilutes the single entity you actually want to rank.

Fix: location pages describe a "service", not a separate business. Emit a Service node that points back to your one business via @id:

const serviceSchema = {
  "@context": "https://schema.org",
  "@type": "Service",
  serviceType: "Air conditioning repair",
  name: `AC Repair in ${city}, FL`,
  provider: { "@id": "https://example.com/#business" },
  areaServed: { "@type": "City", name: `${city}, FL` },
};
Enter fullscreen mode Exit fullscreen mode

One business, many services, many areas. Consistent every time.

Service-area businesses: hide the address on purpose

If you run a service-area business (you go to the customer, and your Google Business Profile hides the street address), do not invent a street address for schema. A fake or mismatched address is worse than none.

Publish locality and region only, and let areaServed carry the coverage:

address: {
  "@type": "PostalAddress",
  addressLocality: "Fort Lauderdale",
  addressRegion: "FL",
  addressCountry: "US",
},
Enter fullscreen mode Exit fullscreen mode

Match this to whatever your Google Business Profile actually shows. Consistency between your schema, your site, and your GBP is the whole game.

Free wins: FAQPage and BreadcrumbList

If you already have FAQ content, wrap it in FAQPage schema. Both Google and LLM assistants consume it, and it's a direct map of your data:

const faqSchema = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: faqs.map((f) => ({
    "@type": "Question",
    name: f.q,
    acceptedAnswer: { "@type": "Answer", text: f.a },
  })),
};
Enter fullscreen mode Exit fullscreen mode

Add BreadcrumbList on inner pages so the crawler understands your hierarchy and can show breadcrumb rich results:

const breadcrumb = {
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  itemListElement: [
    { "@type": "ListItem", position: 1, name: "Home", item: "https://example.com" },
    { "@type": "ListItem", position: 2, name: "Service Areas", item: "https://example.com/locations" },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Validate before you ship

Never eyeball JSON-LD. Run it through:

A quick programmatic check also catches broken JSON before deploy:

document.querySelectorAll('script[type="application/ld+json"]')
  .forEach((s) => JSON.parse(s.textContent)); // throws on invalid JSON
Enter fullscreen mode Exit fullscreen mode

You can inspect a full, validating example live on browardacguys.com (view source and search for application/ld+json).

The takeaway

Structured data is not about volume, it's about a single, consistent, connected entity:

  1. One business node with a stable @id.
  2. Location pages use Service + areaServed, never a second LocalBusiness.
  3. Match your address handling to your real Google Business Profile.
  4. Add FAQPage and BreadcrumbList from data you already have.
  5. Validate every time.

Get those five right and you've done more for your local search presence than another 1,000 words of body copy. The complete implementation is running on the live site if you want a reference.

Top comments (0)