← 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).