Local SEO Pages
Programmatic service-in-city landing pages generated from live listings, plus a browse-all-cities index and state hubs — with anti-thin-content guards for search.
Why this matters
Local intent is where directories win search. This turns your catalogue's location data into real keyword-path pages — /{category}-in-{city}-{region}, e.g. /hvac-services-in-roseville-ca ("HVAC Services in Roseville, CA") — plus city and state hubs and a /locations index. The page set is derived from live listings at runtime, so a directory only ever gets the pages its data supports: no seed list to maintain, no phantom pages.
Turn it on
Flag: localSeo: true in config/features.config.ts (off by default; implied by the localBusiness vertical).
Migrations: 0007_add_location_slugs.sql (normalized city_slug / region_code on apps + facet RPCs) and 0012_normalize_locations.sql (canonical states / cities tables + apps.city_id). If you're on the localBusiness vertical, the bundled APPLY_LOCAL_BUSINESS.sql includes both.
Backfill existing rows so their location slugs are populated:
node scripts/backfill-location-slugs.cjsWhat you get
| Route | Page |
|---|---|
/{category}-in-{city}-{region} | Programmatic landing page with intro copy, a live listing count, a location FAQ and internal links to nearby cities. |
/locations | Browse-all-cities index — stats, a state filter, search and a card grid. |
/locations/state/[state] | State hub page with internal linking and sitemap entries. |
Configure the URLs and copy
File: config/local-seo.config.ts
export const localSeoConfig = {
slugPattern: '{category}-in-{city}-{region}', // your whole URL design
regions: usStates, // swap for another country, or null to drop the region tier
minListingsToIndex: 3, // combos below this render but are noindex'd
prerenderTopN: 250, // top combos prerendered at build; the rest on demand
nearbyCityCount: 6, // internal links per landing page
content: { /* intro + FAQ templates, {item}/{items} tokens */ },
};Swap config/regions/us-states.ts for another country's regions, or set regions: null to drop the region tier entirely (slugs become {category}-in-{city}).
Combos below minListingsToIndex still render but emit noindex, so thin "city-swap" pages stay out of the index. Each real page ships unique copy, a live count, a FAQ (FAQPage JSON-LD) and internal links — depth, not doorways.
A Suspense shell would soft-404 unknown slugs. Top combos prerender via generateStaticParams + dynamicParams; don't add a loading.tsx to the local-SEO routes.