← back to Open Seo

specs/0008-local-rank-tracking-locations.md

79 lines

# 0008 — Local Rank Tracking: Location Data & Search

How city/region-level rank tracking stores and searches DataForSEO's location
registry, and why it works the way it does. Shipped July 2026; future
directions are listed at the end.

## The feature

Rank tracking configs take an optional `location_name` — a canonical DataForSEO
location string like `Enid,Oklahoma,United States`. Null means the existing
country-level behavior, unchanged. Uniqueness is enforced by two partial
indexes: one config per (project, domain, country) for national trackers, one
per (project, domain, country, location) for local ones.

When set, the location flows through verbatim:

- **SERP checks** (live and queued task-post) send `location_name` instead of
  `location_code`, so positions reflect what a searcher in that city sees.
  SERP pricing is location-independent, so cost estimates are unchanged.
- **Keyword metrics** come city-scoped: volume / CPC / competition from Google
  Ads `search_volume` (the only DataForSEO source that accepts sub-country
  geotargets), merged per keyword with national KD / intent from Labs (which
  is country-only). This matters: "rv storage near me" is 135K/mo nationally
  but 70/mo in Pittsburgh — a national number on a local tracker overstates
  demand by orders of magnitude. Keywords Google Ads collapses away get
  explicit nulls rather than a leaked national value; the UI column reads
  "Local volume" and exports name the city. Adds ~$0.09 per metrics refresh.
- **The picker** is a debounced combobox in the config modal, searching the
  country's registry and storing the selected canonical name. Local mode
  requires a selection; switching country clears it.

## Location data: how search works

The registry endpoint (`/v3/serp/google/locations/{iso}`) is free but has no
search parameter and returns the full country list per call — 9.5 MB / 60k
entries for the US. Slimmed to the five types users target (City, County,
Municipality, DMA Region, Region) it is ~23k entries / 1.5 MB. The data
changes roughly quarterly (Google geotarget updates).

The search path: combobox (350 ms debounce) → `searchSerpLocations` server fn
→ per-country list from **KV** (`serp-locations:{iso}`, 30-day
`expirationTtl`, hot reads edge-cached with `cacheTtl: 86400`) → substring
filter, top 10. A KV miss triggers the origin fetch + slim + store, with
concurrent cold fills coalesced in-isolate so the prewarm and a fast first
keystroke can't both download the 9.5 MB payload.

Selecting **Local** in the modal fires `prewarmSerpLocations` (a `useQuery`
keyed on country, `staleTime: Infinity`), so the one slow cold fill (~3 s)
usually happens before the first keystroke. Warm searches are tens of ms.

## Why KV

| Alternative                  | Why not                                                                                                                                                  |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Workers Cache API layer      | Documented no-op on workers.dev (self-hosters), per-colo only, no persistence guarantees. KV's `cacheTtl` provides the same hot-read caching, managed.   |
| R2 (+ cache in front)        | Works (an earlier iteration shipped it) but needs a second layer for read latency; KV is one primitive with the same profile.                            |
| D1 / Postgres table          | Schema + migrations on two providers for quarterly-static reference data; revisit if server-side validation or MCP location search justify a real table. |
| Durable Object per country   | Pins to its first-request region forever; new binding for self-host; buys coordination this read-only data doesn't need.                                 |
| Static assets, client search | Refresh requires redeploy — self-hosters would be pinned to release-time snapshots.                                                                      |
| "Just accept zip codes"      | Doesn't avoid the registry: DataForSEO only accepts canonical values, and zips are registry entries themselves (~32k for the US).                        |

Cost is noise either way: KV bills per operation, so storage for all supported
countries is ~$0.02/month and each search read is fractions of a cent.

## Future directions (not built)

- **Picker quality**: prominence-ranked results (offline GeoNames/Census tier
  table — the registry has no population data, so "Portland" currently ranks
  the Maine DMA above Portland, OR), cities-first with a type filter,
  recently-used/suggested locations (must come from our own config history —
  GSC has no city dimension), and a selection-confirmation line.
- **ZIP fast path**: numeric queries search a separate cached Postal Code
  blob; useful for sub-metro service areas inside large cities.
- **Multi-city fan-out**: multi-select in the picker creating one config per
  city with a shared keyword set — the agency 3–5-metro workflow.
- **Server-side `location_name` validation** at config save, closing the gap
  where a hand-crafted request can store an arbitrary string (fails at
  DataForSEO at cost 0 today, so client-side validation suffices).