Location Search Without the Expensive API
Every directory needs location-based search. When a user types "nail salon near me" or browses by neighborhood, the site needs to figure out what's nearby and return relevant results. For AiSalonHub, we couldn't justify the cost of a geolocation service like Google Maps API ($7 per 1,000 requests) or Mapbox ($50/month minimum) at launch.
Our solution was to implement location search using only Cloudflare Workers, D1, and ZIP code data — no paid APIs, no external services, and a query cost that rounds to zero.
The ZIP Code Proximity Approach
The core insight is that you don't need precise lat/lng coordinates for a salon directory. Users search by neighborhood, not by GPS coordinates. We built a lightweight ZIP code database indexed directly in D1:
```sql
CREATE TABLE zip_proximity (
zip TEXT PRIMARY KEY,
city TEXT,
state TEXT,
lat REAL,
lng REAL
);
CREATE TABLE zip_distances (
zip_a TEXT,
zip_b TEXT,
miles REAL,
PRIMARY KEY (zip_a, zip_b)
);
```
The `zip_distances` table pre-computes distances between all ZIP codes within a 50-mile radius — about 1.2 million rows for the Chicago metro area. This is a one-time cost of roughly 15 seconds of D1 write time and about 30MB of storage. Every subsequent distance lookup is a simple primary key query rather than a Haversine calculation at runtime.
The Search Query
When a user searches by location, the Worker takes their ZIP code and looks up nearby salons in a single query:
```typescript
async function searchByLocation(zip: string, maxMiles: number, env: Env) {
const { results } = await env.DB.prepare(`
SELECT sal.id, sal.name, sal.address, sal.city, sal.state,
sal.zip, sal.rating, sal.phone,
zd.miles
FROM zip_distances zd
JOIN comparisons sal ON sal.zip_code = zd.zip_b
WHERE zd.zip_a = ?
AND zd.miles <= ?
AND sal.status = 'published'
ORDER BY zd.miles ASC
LIMIT 30
`).bind(zip, maxMiles).all();
return results;
}
```
This query completes in under 3ms even with a 50-mile radius. The pre-computed distance table eliminates all runtime math. Compare this to a traditional Haversine query that would need to compute `acos(sin(lat1)*sin(lat2)+cos(lat1)*cos(lat2)*cos(lng2-lng1))` for every row — that approach adds 5-15ms per query and doesn't scale well past a few thousand entries.
Seeding the ZIP Code Data
The ZIP code proximity data was seeded from the free GeoNames ZIP code dataset. A build-time script processes the raw data, computes pairwise distances within each metro area using the Haversine formula, and outputs the `zip_distances` data as a D1 seed file:
```typescript
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3959; // Earth's radius in miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat/2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng/2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
```
The script runs as a one-off during initial deployment. To update the data — new ZIP codes added by USPS, or adjusted coverage boundaries — we re-run the script and re-seed D1, a process that takes under a minute from start to finish.
The Cache Layer
Location search results are cached aggressively. Since salon locations don't change frequently, a 30-minute edge cache (via Cloudflare's `cache-control` headers) gives a 95%+ cache hit rate for popular neighborhoods. The most searched ZIP codes in Chicago (60610, 60614, 60657) serve from cache almost exclusively after the first request each hour — meaning the D1 query runs maybe twice a day for those routes.
We also implemented a warm-cache strategy: a daily cron worker hits the top-100 ZIP codes after midnight, ensuring the most common searches are pre-cached before the morning browsing spike.
Handling Multiple Cities
If AiSalonHub expands to multiple cities, we simply add a `metro_area` column to the proximity table and scope searches geographically. No architectural changes needed — the same Worker code serves Chicago, Los Angeles, or New York equally well, just with different ZIP code data seeded into D1. The query cost doesn't increase because each city's proximity data is isolated and indexed.
Why This Approach Wins for Niche Directories
The pre-computed proximity approach costs nothing to run, completes queries in milliseconds, and doesn't depend on any third-party API. For a niche directory like AiSalonHub that operates within a metro area, it's a perfect fit. You get production-quality location search that costs exactly zero to operate, and the only engineering investment is a one-day build for the seeding script and the query handler.
Building your own geolocation search is one of those projects that sounds harder than it actually is. With D1's SQL engine, a one-time data seeding pass, and Cloudflare's edge cache, you can compete with any API-powered location search without the recurring bill.