One Config File, Six Cities: Our Local SEO Architecture for Central Indiana
Local SEO for a service business lives and dies by three things: consistent NAP (name, address, phone) everywhere it appears, structured data that crawlers actually see, and city pages that aren't just thin doorway spam. We just shipped a full local SEO overhaul for our own site and want to walk through exactly how we built it.
The single source of truth problem
Before this branch, business name, phone, and service area were scattered across components. Change the phone number and you'd have to grep for it across five files. That's a maintenance trap.
We solved it with a single src/content/local.ts file that exports two things: a localBusiness object (name, phone, email, URL, Google Business Profile link) and a cities array. Every page, schema component, and footer block reads from there. Update the phone once, it propagates everywhere at build time.
export const localBusiness = {
name: "Twenty One Media",
telephone: "+1-765-431-6830",
url: "https://twenty1-media.com",
// ...
};
The city objects hold the slug, county, a unique blurb, and per-city FAQ pairs. The blurbs matter: Google penalizes "doorway pages" that are identical except for a city name swap. Each city has a real differentiator, even if subtle.
Server-rendered JSON-LD
This one trips people up. If you render JSON-LD in a client component, Googlebot may see an empty <script> tag on first load. In Next.js App Router, that means a "use client" component with useEffect is the wrong tool for schema markup.
Our LocalBusinessSchema is a pure server component. It renders a <script type="application/ld+json"> tag with the full ProfessionalService schema directly into the initial HTML. No hydration, no flash, no risk.
// No "use client" — must be in the initial HTML payload
export function LocalBusinessSchema({ city }: Props) {
const data = {
"@context": "https://schema.org",
"@type": "ProfessionalService",
"@id": `${localBusiness.url}/#business`,
// ...
};
return <JsonLd data={data} />;
}
Stable @id for entity merging
Every city page renders the same @id: https://twenty1-media.com/#business. This tells Google all these pages describe the same entity. Without a stable @id, you get fragmented signals. With it, every city page reinforces the same business node in Google's knowledge graph.
City pages via generateStaticParams
The /locations/[city] route uses generateStaticParams to pre-render each city at build time. Six cities (Kokomo, Carmel, Fishers, Westfield, Noblesville, Indianapolis), six static HTML files, zero server compute at request time.
Each city page also injects a FAQPage schema using the city's FAQ pairs. That's two overlapping schema types on one page: ProfessionalService scoped to the city's areaServed, and FAQPage targeting the local search questions we care about.
Footer NAP
The footer now uses a semantic <address> element for the phone and location. It's a small thing, but crawlers weight <address> content appropriately, and it keeps the markup honest about what that block actually is.
What's next
The on-site work is done. The off-site half, Google Business Profile posts, Bing Places, and citation building, is where local rankings actually move. We have a checklist for that. The infrastructure just needed to be solid first.
If you're running a service business in Central Indiana and want this kind of setup on your site, start with a free consultation.