Twenty One Media
webJune 26, 2026

Every City Page Traces Back to One TypeScript File

We have six city landing pages: Kokomo, Carmel, Fishers, Westfield, Noblesville, Indianapolis. Each one has a unique title, unique meta description, city-specific FAQ content, server-rendered JSON-LD, and a canonical URL. None of it is hand-written per page. It all derives from a single array in src/content/local.ts.

The Array

export const cities: City[] = [
  {
    slug: "carmel",
    name: "Carmel",
    county: "Hamilton County",
    blurb: "We serve Carmel service businesses and professional firms with AI operations builds, web, and high-end video — remote-first with on-site shoots as needed.",
    faqs: [
      {
        q: "Who builds AI workflow automation in Carmel, IN?",
        a: "Twenty One Media is a fractional AI operations partner for Carmel service businesses..."
      },
    ],
  },
  // ...five more
];

Each city object has a slug (the URL segment), a blurb (the unique intro paragraph), and a faqs array where each question is written as the actual search query we want to intercept.

What Derives From It

Routes. generateStaticParams() maps the array to route segments:

export function generateStaticParams() {
  return cities.map((c) => ({ city: c.slug }));
}

Next.js pre-renders all six pages at build time. Crawlers get full HTML from the CDN, no server round-trip, schema included.

Metadata. generateMetadata() builds unique titles and descriptions per city:

const description = `${localBusiness.name} is a fractional AI operations partner for ${found.name} and ${found.county} service businesses...`;

The phrase "fractional AI operations partner" appears in every city page's meta description. AI answer engines that read multiple pages in the same crawl see the same phrase repeated across six different geo contexts. That repetition is the signal.

FAQPage schema. The CityLanding component owns its schema. It calls faqPageSchema(city.faqs) and renders the output via <JsonLd>. If you render the component, the schema comes with it. There's no separate wiring step.

LocalBusiness schema. Each city page also renders <LocalBusinessSchema city={found.name} />, which emits a ProfessionalService node with @id: "https://twenty1-media.com/#business". Same @id on all six pages. Crawlers reading them see one entity, not six.

Service areaServed. The /ai page's Service schema pulls from:

export const cityServiceArea = cities.map((c) => c.name);

One derived constant. The city list in the schema matches the city list with pages, because they come from the same source.

The Anti-Doorway Constraint

The blurb field has a comment in the type definition: // unique, city-specific intro (>40 chars). Anti-doorway bar. It exists to force genuinely different copy per city. A doorway page is one that exists only to funnel a user to a different page, with no value of its own. Unique blurbs and city-specific FAQ answers are the minimum bar that distinguishes a legitimate geo landing page from one Google would penalize.

What This Gets Right

Adding a seventh city means editing one file: add an object to the array with a slug, a blurb, and at least one FAQ. The new route, the metadata, the schema, and the areaServed field all update from that single change. Nothing to remember. Nothing to drift.

The pattern works because each surface that needs city data imports from the same source. The footer <address>, the schema components, the city page metadata, and the Service schema on /ai are all reading from local.ts. One change propagates everywhere.