Six City Pages, One Entity: The @id That Holds Them Together
When we built the local SEO branch, we created six city landing pages: Kokomo, Carmel, Fishers, Westfield, Noblesville, Indianapolis. Each page renders a LocalBusinessSchema component scoped to that city's areaServed. The intent is to tell search engines we serve businesses in each of those markets.
That structure has a problem most people don't catch until they check their structured data logs.
The Merging Problem
Each city page emits a distinct JSON-LD block with a different areaServed value. Without anything tying them together, a crawler processing these pages has no guarantee they refer to the same business. It sees six separate ProfessionalService nodes. It might merge them. It might not. The spec says JSON-LD entities are the same only if they share an @id.
We added one:
const data = {
"@context": "https://schema.org",
"@type": "ProfessionalService",
"@id": `${localBusiness.url}/#business`,
name: localBusiness.name,
areaServed: city ? [city] : cityServiceArea,
// ...
};
Every page now emits "@id": "https://twenty1-media.com/#business". Google and Bing see six pages declaring facts about the same entity. The areaServed values from each page combine into one picture of where we operate, rather than six separate, potentially conflicting pictures.
One Config, No Drift
The second issue was consistency. Our footer has an <address> block with our business name, region, and phone number. Our JSON-LD schema has those same values. If one drifts from the other, search engines notice the discrepancy.
The fix is to make drift structurally impossible. Everything pulls from one config:
// src/content/local.ts
export const localBusiness = {
name: "Twenty One Media", // MUST match Google Business Profile exactly
telephone: "+1-765-431-6830",
region: "Central Indiana",
// ...
};
The footer <address> block renders {localBusiness.name}. The schema component reads localBusiness.name. If we ever need to update the business name, we change it in one place and both surfaces update. There's no separate string in a schema component to forget.
We use <address> specifically, not <p>:
<address className="mt-4 text-sm text-muted/80 not-italic">
{localBusiness.name}
<br />
Serving {localBusiness.region}
<br />
<a href={`tel:${localBusiness.telephone}`}>{localBusiness.telephone}</a>
</address>
The <address> element is the correct semantic container for contact and location information associated with the nearest <article> or <body>. Using it means the markup signals the same thing the schema does: this is contact data, not prose.
Why the Test Confirms It
We added an assertion to the schema unit test:
expect(data["@id"]).toBe("https://twenty1-media.com/#business");
This seems trivial. It matters because @id is easy to lose in a refactor. Someone moves the schema data construction into a helper, forgets to carry the @id field, and now six pages silently go back to emitting disconnected entities. The test catches that before it ships.
What This Looks Like in Practice
Each city page calls <LocalBusinessSchema city={found.name} />. The component scopes areaServed to that city. But the @id, name, telephone, and url are identical on every page because they come from the same config object. Google's knowledge graph can build a complete picture of the business from those signals: one entity, serving six markets, reachable at one number.
The pattern scales to any number of city pages without any additional maintenance. Add a city to the cities array in local.ts and both the page and the schema update together.