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) }}
/>
);
}
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",
},
},
],
};
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` },
};
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",
},
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 },
})),
};
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" },
],
};
Validate before you ship
Never eyeball JSON-LD. Run it through:
- Google's Rich Results Test
- The Schema Markup Validator
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
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:
- One business node with a stable
@id. - Location pages use
Service+areaServed, never a secondLocalBusiness. - Match your address handling to your real Google Business Profile.
- Add
FAQPageandBreadcrumbListfrom data you already have. - 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)