Case Study · Personal Project

Local Haunts

A discovery app for independent restaurants, built because Google’s search hides the places it was made to find. Every design decision follows from one constraint: each search costs real money, so the product only works if the signal stays honest and the unit cost stays visible. Built solo, end to end.

2 to 8 Google Calls Per Search
985 Chain Names Filtered
6 Countries
310 Automated Tests

The Failure

A burger joint locals love was invisible from anywhere in Charlotte.

20The most results Google returns for one search, with no next page
5 miA search that returns the 20 most famous restaurants in range
0Neighborhood spots among them: prominence ranking buries exactly the places this app exists to find

Google’s nearby search ranks by prominence, which in a city means the 20 most famous restaurants and never the corner spot. The data was there; the ranking buried it. I treated that as the product: every search runs twice, once by distance and once by popularity, and the two lists are merged.

I ruled out review sites on purpose. Ratings need volume to mean anything, and free text brings a moderation burden a solo developer cannot carry. People vouch with one tap instead, and the rest of the app is built to keep that signal honest and cheap.

One Search, Followed

What happens between opening the map and seeing a result, and what each step costs.

  1. You Open The Map

    Location is asked once, after an explanation, and never answered from a made-up position. An early build fell back to a leftover mock city, which served Asheville restaurants to anyone who declined. The default area is Walking, about a mile.

  2. The Cache Answers First

    Searches are keyed by geohash, radius and tier and kept for an hour, so everyone searching the same cell shares one Google bill. A hit costs nothing. Place details live 48 hours and reviews seven days, because reviews are a pricier field tier.

  3. A Fan-Out Of Calls

    The first two calls are always centered: one ranked by distance, one by popularity. Anything more is a ring of distance-ranked cells around you, sized so no result can come from beyond the area you chose. The table below shows the budget.

  4. Two Lists Become One

    The passes are merged and de-duplicated. In Charlotte, 20 results became 28 to 37, and the mix shifted from landmarks to neighborhood places.

  5. The Filter Removes Chains

    Non-food venues go first, then the country’s chain blocklist, a repetition rule inside the search, and a learned location count. The next section covers it.

  6. Ranking

    Distance is a half-life decay multiplier, never a divisor: dividing made proximity swamp everything inside a mile and “Recommended” came out identical to “Distance.” Quality is rating-led and normalized over 3.0 to 5.0, with review count setting only how much to trust it. Vouches and crowns add a saturating boost capped at 2.0, enough to beat a better-rated neighbor and never one across town.

  7. Thin Results Widen Themselves

    A search returning fewer than five places steps outward automatically and says what it did: “Only 2 within walking range. Showing Nearby.” Tapping the notice adopts the wider radius; otherwise your saved preference is untouched.

The Call Budget

Google Calls Per SearchWalking (1 mi)Nearby (5 mi)Citywide (15 mi)
Free246
Pro468
Cost, uncached, free tierabout $0.06about $0.13about $0.19

The Chain Filter, Country By Country

McDonald’s is a chain everywhere. Tim Hortons is only a Canadian problem.

CountryOwn EntriesExact-Match OnlyEffective Set
United States62721627
Canada12124748
Australia758702
New Zealand314658
Singapore585685
Hong Kong732700

Signal Without Words

Neither mark accepts text, so there is nothing to moderate.

MarkCostLimitWhat Others SeeGuard
VouchFreeOne per person per place“Vouched” below three, a count from three upConfirmed email, 15-minute account age, 20 a day, counted from an append-only ledger
CrownProOne per dish category; a new one displaces the oldCounts triple on Local LegendsServer checks Pro against its own record, never the client’s
Been HereFreeOne flag per placeNobody; privateEarlier timestamp wins when two devices disagree
Saved To The CachéFree500, an abuse guardrail rather than a tier lineNobody; never countedLists are labels across one set, so a lapsed subscription strands nothing

Why Six Countries

I judged every candidate on language, units, legal exposure, data and money. The launch list came from the legal gate, not from market size.

RegionLanguageLegalVerdict
US, Canada, Australia, New Zealand, Singapore, Hong KongEnglish-firstCommon law; no local representative requiredLaunched, each only after its chain list existed
EU, EEA and UKEnglish works in the north and westGDPR Article 27 needs a paid representative, about USD 800 to 1,200 a year for the EU and UK pairDeferred a year, revisited on traction
Latin AmericaEnglish-only is a non-starterMostly no representative neededOut until the app is localized
Turkey, Russia, BelarusNot the deciding gateRegistration paperwork or data localizationOut

What It Costs, What It Earns

Pricing follows the cost model: usage-based costs argue against anything uncapped.

$9.99Pro, per year. There is no monthly tier: nobody subscribes monthly to a weekly-use app.
$29.99Lifetime for the first 250 buyers, who become Founders and the beta cohort
$49.99Lifetime for the other 750 of a batch capped at 1,000

Failures That Looked Like Something Else

The expensive bugs were the ones that looked like something else. Each is now a line in a gotchas file and, where possible, a check.

What I SawWhat It WasWhat Changed
Purchases worked and every dashboard looked healthyThe purchase webhook had never succeeded: a trailing newline in its secret answered 401 to every delivery, hidden because the app writes the same record itselfBoth sides trim the value; a subscription expiring, which the client cannot fake, is the test
Nothing a signed-in user saved reached the server for about three and a half weeksA fixed local id, “default,” did not fit a uuid column, and the error printed as “[object Object]”The default list takes the user’s id on the server; every log site extracts the message
The app hung on its splash screen from a shared linkNo route existed for the share path, hidden for months because Android never verified the domainA redirect route and a not-found screen, so no link shape can look like a dead app
Push alerts never arrived on AndroidNo Firebase config in the build, so no token was ever minted; the function answered “sent: 0,” which is also what it says when nobody is registeredConfig shipped; a push feature is verified by receiving a push
A wider radius returned fewer placesThe call count came from tier alone, so widening reached more chains for the filter to removeCalls scale with the area, and a script pins the counts and geometry
Sheets drew a grab handle that could not be draggedThe gesture lived on a 22-point strip nobody grabs, then trailed the finger at JavaScript frame rateThe whole sheet is the target, on the native gesture libraries

Under The Hood

An Expo app, a Supabase backend that holds every key, and Google reached only through functions that cache, filter and sign.

App

Expo SDK 55, React Native, Expo Router, TypeScript. Map, List, Saved and Settings. iOS and Android from one codebase.

Supabase

Postgres with PostGIS and row-level security, 15 migrations, and 9 Deno Edge Functions: search, details, reviews, photos, alerts, webhook, entitlement sync, deletion and logs.

Google Places

Server-side key only. Photos go through an HMAC-signed proxy with bucketed expiries, so one leaked URL cannot be looped to run up the bill.

Services

RevenueCat for purchases, Sentry for opt-in crash reports, Expo push with FCM for vouch alerts.

React Native Expo TypeScript Supabase PostGIS Deno Google Places API RevenueCat Sentry Jest EAS

Every request has a price, and the design only holds if each feature can pay for its own.

The through-line with my professional work is the unit cost. In FinOps the question is what a workload costs per unit of value. Here the unit is a search, and most of the architecture exists to make sure nobody, including me, can quietly make it more expensive.
 Visit localhaunts.app
 Back to Projects