← Work

NearbyLens

Natural-language place discovery where the model ranks real places from a geospatial database and never invents them

Role
Sole designer and builder
Status
In development
Period
2026
Stack
Next.js 16, TypeScript, Supabase

Problem

Knowing what surrounds a location is split across maps, reviews, and routing tools. None of them answer the question people actually ask: "what is worth doing near me right now, and how do I get there?"

I wanted to ask that in plain words, or with a photo, and get a short, specific answer grounded in real places with real coordinates. A model that writes confident paragraphs about places that do not exist is worse than no answer.

Constraints

  • Grounded or nothing. The plan for this phase lists one non-goal explicitly: do not replace curated database content with unconstrained LLM place facts.
  • Cost. Every external call is a line item. Travel times in day plans use a straight-line heuristic, and the code says why: the Directions API was left out of v1 for cost control.
  • Cold areas. A new location may have no curated places at all, so the app has to discover places on demand without hammering free public APIs.

Architecture

Client
query + lat/lng
/api/ask
Zod, rate limit
Supabase places
RPC, grid cache
Overpass discovery
on cold areas
LLM via OpenRouter
picks 3 by index
NDJSON stream
validated per line
Ask flow: the database proposes candidates, the model only chooses among them

A request carries the query and the user's coordinates. The route validates the body with Zod and applies a per-IP rate limit of 20 requests a minute. It loads nearby places from a Supabase RPC, snapped to a roughly 1.1 km grid and cached for five minutes. If the area has nothing, it falls back to discovery: an Overpass query over 35 OpenStreetMap tag mappings, capped at five places per category and 150 in total, written back to Postgres with embeddings and cached per roughly 10 km cell for 24 hours.

The 25 nearest candidates go to the model as a numbered list. The model must return exactly three JSON lines, each an index into that list plus a short explanation. The server buffers the token stream, splits on newlines, validates each line with Zod, maps the index back to the real place, and writes it to the client as newline-delimited JSON. Anything that does not parse or points outside the list is dropped.

The same rule shapes the other AI features. Lens sends a photo to Gemini 2.0 Flash with a schema (main subject plus 4 to 20 keywords), normalizes the keywords into tags, and finds places by array overlap on places.tags. Day plans are built by a deterministic engine: nearest-neighbor ordering with quality as a tie-break, time-of-day blocks inferred from place type, dwell estimates per type. An optional free-text preference lets a model score candidates from 0 to 10, and those scores only nudge the ranking. The engine never emits an ID outside its candidate pool.

Key decisions

  1. The model chooses; it does not author places. Candidates come from the database, the model returns an index, and the server resolves the index. A hallucinated place has nowhere to go. The trade-off: the model can only recommend among the 25 nearest candidates, so a great place 26th by distance is invisible to it.

  2. Stream line-delimited JSON instead of one JSON blob. Each recommendation is a complete object on its own line, so the first card renders as soon as its line finishes instead of after the whole answer. The cost is owning the buffering: partial lines, a trailing line after the stream ends, and an explicit error event when nothing valid came back.

  3. Cache by grid cell, not by exact coordinate. Nearby queries snap to a two-decimal grid; discovery snaps to a 0.1 degree grid with a 24-hour TTL stored in a Postgres table, behind two Overpass mirrors with an 8 second timeout each. Users in the same cell share work. The trade-off is precision: distances for cached nearby results are computed from the cell center, not the user's exact position.

  4. Providers sit behind one port. Gemini, Groq, and OpenRouter each implement the same getLanguageModel interface through the AI SDK, so each feature picks the provider that fits. Today vision and embeddings use Gemini, recommendations go through OpenRouter, and the nightly curation job and taxi-card translation use OpenAI models through OpenRouter.

  5. Deterministic first, AI on top. Day-plan ordering, hotspot clustering (0.02 degree cells, at least three places, at most 16 clusters), open-now filtering, and the "I have X hours" time-budget filter are plain functions. AI adds preference scoring and copy. That keeps plans explainable and cheap, at the cost of less clever itineraries.

  6. Curation runs offline. A Vercel cron job runs nightly, takes up to five places not yet evaluated, and asks a model with a structured schema whether each is a real experience or a generic utility like an ATM. Good ones get an experience record with vibes, duration, and weather and time suitability, which a context ranker later scores against live weather, time of day, and open-now state. A place is marked evaluated even when the call fails, so a broken prompt cannot loop forever. The trade-off is that failed places are never retried.

What was hard

The commit history, from the first commit on 30 April 2026 to the latest in June, shows where the time went: fixes for API timeouts, caching, location access, and exploring places through Overpass, plus a move from MapLibre to Mapbox GL JS and the Mapbox Directions API.

  • Discovery sat on the request path. The nearby route used to await Overpass discovery before responding. It tried two mirrors in turn with a 20 second timeout each, behind a 25 second Overpass query timeout, so in a cold area the map could wait on Overpass for up to 40 seconds. The fix moved discovery off the request: the route now reads only places already cached for the cell and schedules discovery with Next.js after() once the response is sent. Mirror timeouts dropped to 8 seconds, the Overpass query timeout to 10, and the route got a 25 second maxDuration. The trade-off is that the first visit to a cold area shows only places already in the database, and discovered places appear on the next request. Ask still waits for discovery when the database has nothing, because it cannot answer without candidates.
  • Real routes, with their own failure mode. The first version drew MapLibre over CARTO basemaps and had no routing. The Mapbox change switched to Mapbox navigation styles for day and night and added Directions API routes: when a place is selected, walking, cycling, and driving-with-traffic routes are fetched in parallel, each with a 10 second abort, and the active mode's road-following line and duration appear on the map. One problem is recorded in the code: ad and privacy blockers reject Mapbox's telemetry endpoint, which can stop the map's load event from firing, so the map client drops those requests. Day plans still use the straight-line estimate.

Results

Shipped so far: natural-language Ask with streamed picks, photo Lens search, day plans with travel modes, hotspot clusters on the map, search with filters for type, price, open now, and time budget, and a taxi card that translates a place's name and address into Hindi, Tamil, or Telugu script for drivers.

What I'd change

  • Move rate limiting out of memory. The limiter is a fixed-window counter in process memory, and the code itself notes that a distributed deploy needs Redis or edge KV. On serverless, each instance keeps its own count.
  • Use one structured-output path everywhere. Ask and Lens validate with Zod, but day-plan preference scoring asks for raw JSON, strips code fences, and silently returns empty scores on a parse failure.
  • Raise curation throughput. Five places a night is safe but slow for a growing places table.
Next case studyAccounting and ERP Integrations →