Skip to main content

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.cjs

What you get

RoutePage
/{category}-in-{city}-{region}Programmatic landing page with intro copy, a live listing count, a location FAQ and internal links to nearby cities.
/locationsBrowse-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}).

Anti-doorway guards keep you safe with Google

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.

No loading.tsx on these routes — by design

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.