The pattern
A mobile app or a web page is code your users can read. Anything shipped inside it, an API key included, can be pulled out and used by someone else until you replace it. So the app never talks to Overspan directly. It asks a small function of yours, and the function adds the key:
your app
GET /nearby?kind=cafe&lat=52.520&lon=13.405 (no key)
↓
your function
checks the request, adds your key, caches the answer
↓
Overspan
POST /api/interpreter (Authorization: Bearer YOUR_KEY)
The function also does three jobs that save you money and trouble:
- It only asks fixed questions. The app sends a place type and a point; the function builds the Overpass query. Anyone who finds your function can ask the same questions your app can, and nothing else.
- It caches. Locations are rounded to about 100 metres and answers are kept for a day, so a thousand users on the same street cost you one request, not a thousand.
- It passes our limits on. When Overspan asks you to wait, the function tells the app instead of retrying in a loop.
The shared part
One file does the real work, and it is the same on every platform below. It knows eight place types, turns a request into one Overpass query, and returns the places sorted by distance with the OpenStreetMap credit attached.
// nearby.ts: the part that is the same on every platform.
// It turns an app's "what is near me?" request into one fixed Overpass query,
// sends it to Overspan with your key, and returns a small JSON answer.
// How long an answer can be reused. Shops and amenities in OpenStreetMap
// change slowly, so a day is safe; lower it if your app needs fresher data.
export const CACHE_SECONDS = 24 * 60 * 60;
// The only things your app can ask for. Fixed filters mean nobody can send
// their own queries through your function and spend your key's quota.
const KINDS: Record<string, string> = {
cafe: '["amenity"="cafe"]',
restaurant: '["amenity"="restaurant"]',
pharmacy: '["amenity"="pharmacy"]',
toilets: '["amenity"="toilets"]',
drinking_water: '["amenity"="drinking_water"]',
charging_station: '["amenity"="charging_station"]',
playground: '["leisure"="playground"]',
supermarket: '["shop"="supermarket"]',
};
const RADII = [250, 500, 1000, 2000];
export interface NearbyQuery {
kind: string;
radius: number;
lat: number;
lon: number;
}
export interface Place {
id: string; // the OpenStreetMap element, e.g. "node/123" or "way/456"
name: string | null;
lat: number;
lon: number;
distance_m: number; // from the rounded point the answer was made for
tags: Record<string, string>;
}
export interface NearbyBody {
attribution: string;
kind: string;
radius: number;
lat: number;
lon: number;
places: Place[];
}
export type NearbyResult =
| { ok: true; body: NearbyBody }
| { ok: false; status: number; error: string; retryAfter?: string };
interface OverpassElement {
type: string;
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
// Check the app's request. Coordinates are rounded to 3 decimals (about
// 100 m), so everyone on the same block shares one cached answer.
export function parseNearby(params: URLSearchParams): NearbyQuery | string {
const kind = params.get("kind") ?? "cafe";
const radius = Number(params.get("radius") ?? 500);
const lat = parseFloat(params.get("lat") ?? "");
const lon = parseFloat(params.get("lon") ?? "");
if (!Object.hasOwn(KINDS, kind))
return `kind must be one of: ${Object.keys(KINDS).join(", ")}`;
if (!RADII.includes(radius)) return `radius must be one of: ${RADII.join(", ")}`;
if (!(Math.abs(lat) <= 90 && Math.abs(lon) <= 180)) return "lat and lon are required";
return { kind, radius, lat: round3(lat), lon: round3(lon) };
}
export function cacheKey(q: NearbyQuery): string {
return `${q.kind}/${q.radius}/${q.lat}/${q.lon}`;
}
export async function fetchNearby(
q: NearbyQuery,
apiKey: string | undefined,
baseUrl = "https://api.overspan.dev",
): Promise<NearbyResult> {
if (!apiKey) {
console.error("OVERSPAN_API_KEY is not set");
return { ok: false, status: 500, error: "not_configured" };
}
const query =
`[out:json][timeout:25];` +
`nwr${KINDS[q.kind]}(around:${q.radius},${q.lat},${q.lon});` +
`out center tags 200;`;
const res = await fetch(`${baseUrl}/api/interpreter`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: new URLSearchParams({ data: query }),
});
const text = await res.text();
let data: {
elements?: OverpassElement[];
remark?: string;
error?: { code?: string };
} | null = null;
try {
data = JSON.parse(text);
} catch {
// Not JSON: treated as a failure below.
}
if (res.status === 429 || res.status === 503) {
// A rate, concurrency or monthly limit, or a busy server. Pass the wait
// on to the app and never retry in a loop from here.
const error = data?.error?.code ?? "busy";
console.warn("Overspan said wait:", res.status, error);
return {
ok: false,
status: res.status,
error,
retryAfter: res.headers.get("Retry-After") ?? "60",
};
}
if (res.status !== 200 || !Array.isArray(data?.elements) || data?.remark) {
// A wrong key, or a query that failed on the server. Logged for you;
// the app only needs to know it did not work.
console.error(
"Overspan request failed:",
res.status,
data?.error?.code ?? data?.remark ?? text.slice(0, 200),
);
return { ok: false, status: 502, error: "upstream_error" };
}
const places: Place[] = [];
for (const e of data!.elements!) {
const lat = e.lat ?? e.center?.lat;
const lon = e.lon ?? e.center?.lon;
if (lat === undefined || lon === undefined) continue;
places.push({
id: `${e.type}/${e.id}`,
name: e.tags?.name ?? null,
lat,
lon,
distance_m: Math.round(distance(q.lat, q.lon, lat, lon)),
tags: e.tags ?? {},
});
}
places.sort((a, b) => a.distance_m - b.distance_m);
return {
ok: true,
body: {
attribution: "© OpenStreetMap contributors",
kind: q.kind,
radius: q.radius,
lat: q.lat,
lon: q.lon,
places,
},
};
}
// The response your function sends back to the app. Good answers may be
// cached for CACHE_SECONDS; errors never are.
export function toResponse(
result: NearbyResult,
extraHeaders: Record<string, string> = {},
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json; charset=utf-8",
...extraHeaders,
};
if (result.ok) {
headers["Cache-Control"] =
`public, max-age=${CACHE_SECONDS}, s-maxage=${CACHE_SECONDS}`;
return new Response(JSON.stringify(result.body), { status: 200, headers });
}
headers["Cache-Control"] = "no-store";
if (result.retryAfter) headers["Retry-After"] = result.retryAfter;
return new Response(JSON.stringify({ error: result.error }), {
status: result.status,
headers,
});
}
function round3(x: number): number {
return Math.round(x * 1000) / 1000;
}
// Great-circle distance in metres.
function distance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const rad = Math.PI / 180;
const a =
Math.sin(((lat2 - lat1) * rad) / 2) ** 2 +
Math.cos(lat1 * rad) *
Math.cos(lat2 * rad) *
Math.sin(((lon2 - lon1) * rad) / 2) ** 2;
return 12_742_000 * Math.asin(Math.sqrt(a));
}
To offer another place type, add a line to KINDS with any Overpass tag
filter. The radius list is fixed for the same reason the types are: fewer distinct
questions means more answers come from the cache.
What your app gets back:
{
"attribution": "© OpenStreetMap contributors",
"kind": "cafe",
"radius": 500,
"lat": 52.52,
"lon": 13.405,
"places": [
{
"id": "node/1794131335",
"name": "Espresso House",
"lat": 52.5196945,
"lon": 13.4036191,
"distance_m": 99,
"tags": { "amenity": "cafe", "brand": "Espresso House", "wheelchair": "yes", ... }
},
...
]
}
Your function
Pick the platform your app already uses. Each version is nearby.ts plus a
few lines, and each keeps the key in that platform's secret store.
Cloudflare Workers
Put nearby.ts in src/ next to this file:
import { cacheKey, fetchNearby, parseNearby, toResponse } from "./nearby";
interface Env {
OVERSPAN_API_KEY: string; // set with: npx wrangler secret put OVERSPAN_API_KEY
OVERSPAN_API_URL?: string; // leave unset; only for testing against a stand-in
}
// Lets a web app on another domain call this Worker. Put your site's
// origin here instead of * if you prefer.
const CORS = { "Access-Control-Allow-Origin": "*" };
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname !== "/nearby") return new Response("Not found", { status: 404 });
const q = parseNearby(url.searchParams);
if (typeof q === "string")
return toResponse({ ok: false, status: 400, error: q }, CORS);
// Cloudflare's cache is per data centre, and Cloudflare documents it
// for Workers on a custom domain.
const key = new Request(`${url.origin}/nearby/${cacheKey(q)}`);
const cached = await caches.default.match(key);
if (cached) return cached;
const result = await fetchNearby(q, env.OVERSPAN_API_KEY, env.OVERSPAN_API_URL);
const response = toResponse(result, CORS);
if (result.ok) ctx.waitUntil(caches.default.put(key, response.clone()));
return response;
},
};
{
"name": "nearby",
"main": "src/index.ts",
"compatibility_date": "2026-09-01"
}
Store the key as a secret, then deploy:
npx wrangler secret put OVERSPAN_API_KEY
npx wrangler deploy
Your app then calls https://YOUR_DOMAIN/nearby?kind=cafe&lat=52.520&lon=13.405.
Attach the Worker to your own domain: Cloudflare documents its cache for Workers on a
custom domain, and the cache is kept per data centre.
Next.js on Vercel
Put nearby.ts in lib/ and this in
app/api/nearby/route.ts:
import type { NextRequest } from "next/server";
import { fetchNearby, parseNearby, toResponse } from "@/lib/nearby";
// Vercel's CDN keeps each good answer for a day (the s-maxage that
// toResponse sets), keyed by URL. The app rounds coordinates before it
// calls, so users on the same block share one cached answer.
export async function GET(request: NextRequest) {
const q = parseNearby(request.nextUrl.searchParams);
if (typeof q === "string") return toResponse({ ok: false, status: 400, error: q });
return toResponse(
await fetchNearby(q, process.env.OVERSPAN_API_KEY, process.env.OVERSPAN_API_URL),
);
}
Add OVERSPAN_API_KEY under the project's environment variables in Vercel,
and in .env.local for local development. Vercel's CDN keeps each answer by
URL and per region, which is why the app-side code below rounds coordinates before it
calls. On other hosts the same route runs unchanged, and caching depends on that host's
CDN.
Supabase Edge Functions
Supabase has no response cache for functions, so answers are kept in a small table.
Create it with a migration in supabase/migrations/:
-- Answers from Overspan, kept so nearby users share one request.
create table public.overpass_cache (
key text primary key,
body jsonb not null,
fetched_at timestamptz not null default now()
);
-- Row level security on with no policies: only the function's admin
-- client can read or write it, never the app directly.
alter table public.overpass_cache enable row level security;
Put nearby.ts next to the function's index.ts:
import { withSupabase } from "npm:@supabase/server";
import {
CACHE_SECONDS,
cacheKey,
fetchNearby,
parseNearby,
toResponse,
} from "./nearby.ts";
// Supabase has no response cache for functions, so answers are kept in a
// small table (overpass_cache), shared by every copy of this function.
export default {
// "publishable": the app calls with your project's publishable key in an
// `apikey` header. Use "user" instead to allow signed-in users only.
fetch: withSupabase({ auth: "publishable" }, async (req, ctx) => {
const q = parseNearby(new URL(req.url).searchParams);
if (typeof q === "string") return toResponse({ ok: false, status: 400, error: q });
const key = cacheKey(q);
const { data: hit, error } = await ctx.supabaseAdmin
.from("overpass_cache")
.select("body, fetched_at")
.eq("key", key)
.maybeSingle();
if (error) console.error("cache read failed:", error.message);
if (hit && Date.now() - Date.parse(hit.fetched_at) < CACHE_SECONDS * 1000) {
return toResponse({ ok: true, body: hit.body });
}
const result = await fetchNearby(
q,
Deno.env.get("OVERSPAN_API_KEY"),
Deno.env.get("OVERSPAN_API_URL"),
);
if (result.ok) {
const { error } = await ctx.supabaseAdmin
.from("overpass_cache")
.upsert({ key, body: result.body, fetched_at: new Date().toISOString() });
if (error) console.error("cache write failed:", error.message);
}
return toResponse(result);
}),
};
The function checks for your project's publishable key rather than a signed-in user, so turn off Supabase's default sign-in check for it:
[functions.nearby]
verify_jwt = false
Put OVERSPAN_API_KEY=YOUR_KEY in supabase/functions/.env (keep
that file out of git), then push it and deploy:
supabase secrets set --env-file supabase/functions/.env
supabase functions deploy nearby
Your app calls https://YOUR_PROJECT.supabase.co/functions/v1/nearby with
your publishable key in an apikey header. Old rows are replaced when that
spot is asked for again; to keep the table small, delete stale ones on a schedule:
delete from public.overpass_cache where fetched_at < now() - interval '2 days';
Calling it from your app
All three call your function, never Overspan. They round the location first, so the cache is shared, and when your function says wait they report it instead of retrying.
React
"use client";
import { useEffect, useState } from "react";
export interface Place {
id: string;
name: string | null;
lat: number;
lon: number;
distance_m: number;
tags: Record<string, string>;
}
interface NearbyState {
places: Place[] | null;
error: string | null;
loading: boolean;
}
// Asks your own endpoint (never Overspan directly) for places near a point.
// Coordinates are rounded to 3 decimals, so the URL matches what other users
// on the same block ask for and the cache can answer it.
export function useNearby(
lat: number | null | undefined,
lon: number | null | undefined,
kind = "cafe",
radius = 500,
endpoint = "/api/nearby",
): NearbyState {
const [state, setState] = useState<NearbyState>({
places: null,
error: null,
loading: false,
});
const latKey = lat?.toFixed(3);
const lonKey = lon?.toFixed(3);
useEffect(() => {
if (latKey === undefined || lonKey === undefined) return;
const controller = new AbortController();
setState((s) => ({ ...s, loading: true, error: null }));
const params = new URLSearchParams({
kind,
radius: String(radius),
lat: latKey,
lon: lonKey,
});
fetch(`${endpoint}?${params}`, { signal: controller.signal })
.then(async (res) => {
const body = await res.json().catch(() => ({}));
if (res.status === 429 || res.status === 503)
throw new Error("Busy, try again in a moment");
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
setState({ places: body.places, error: null, loading: false });
})
.catch((err) => {
if (err.name !== "AbortError")
setState({ places: null, error: err.message, loading: false });
});
return () => controller.abort();
}, [latKey, lonKey, kind, radius, endpoint]);
return state;
}
const { places, error, loading } = useNearby(position?.lat, position?.lon, "pharmacy");
Flutter and Dart
Uses the http package (dart pub add http):
import 'dart:convert';
import 'package:http/http.dart' as http;
class Place {
final String id;
final String? name;
final double lat;
final double lon;
final int distanceM;
final Map<String, String> tags;
Place.fromJson(Map<String, dynamic> j)
: id = j['id'] as String,
name = j['name'] as String?,
lat = (j['lat'] as num).toDouble(),
lon = (j['lon'] as num).toDouble(),
distanceM = j['distance_m'] as int,
tags = Map<String, String>.from(j['tags'] as Map);
}
class NearbyException implements Exception {
final int status;
final String error;
final Duration? retryAfter; // set when the server asks you to wait
NearbyException(this.status, this.error, this.retryAfter);
@override
String toString() => 'NearbyException($status, $error)';
}
/// Asks your own endpoint (never Overspan directly) for places near a point.
/// For the Supabase version, pass headers: {'apikey': yourPublishableKey}.
Future<List<Place>> fetchNearby(
Uri endpoint,
double lat,
double lon, {
String kind = 'cafe',
int radius = 500,
Map<String, String> headers = const {},
}) async {
final uri = endpoint.replace(queryParameters: {
'kind': kind,
'radius': '$radius',
// Rounded to about 100 m, so nearby users share one cached answer.
'lat': lat.toStringAsFixed(3),
'lon': lon.toStringAsFixed(3),
});
final res = await http.get(uri, headers: headers);
if (res.statusCode != 200) {
final seconds = int.tryParse(res.headers['retry-after'] ?? '');
var error = 'http_${res.statusCode}';
try {
error = (jsonDecode(res.body) as Map)['error'] as String;
} catch (_) {}
throw NearbyException(res.statusCode, error,
seconds == null ? null : Duration(seconds: seconds));
}
final body = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
return (body['places'] as List)
.map((p) => Place.fromJson(p as Map<String, dynamic>))
.toList();
}
final places = await fetchNearby(
Uri.parse('https://YOUR_DOMAIN/nearby'),
52.5201,
13.4049,
kind: 'pharmacy',
);
Swift
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking // Linux only; iOS and macOS do not need it
#endif
struct Place: Decodable {
let id: String
let name: String?
let lat: Double
let lon: Double
let distanceM: Int
let tags: [String: String]
}
struct NearbyError: Error {
let status: Int
let error: String
let retryAfter: TimeInterval? // set when the server asks you to wait
}
/// Asks your own endpoint (never Overspan directly) for places near a point.
/// For the Supabase version, pass headers: ["apikey": yourPublishableKey].
func fetchNearby(
endpoint: URL, lat: Double, lon: Double, kind: String = "cafe",
radius: Int = 500, headers: [String: String] = [:]
) async throws -> [Place] {
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "kind", value: kind),
URLQueryItem(name: "radius", value: String(radius)),
// Rounded to about 100 m, so nearby users share one cached answer.
URLQueryItem(name: "lat", value: String(format: "%.3f", lat)),
URLQueryItem(name: "lon", value: String(format: "%.3f", lon)),
]
var request = URLRequest(url: components.url!)
for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
let (data, response) = try await URLSession.shared.data(for: request)
let http = response as! HTTPURLResponse
guard http.statusCode == 200 else {
let body = try? JSONDecoder().decode([String: String].self, from: data)
let wait = http.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init)
throw NearbyError(
status: http.statusCode,
error: body?["error"] ?? "http_\(http.statusCode)",
retryAfter: wait)
}
struct Body: Decodable { let places: [Place] }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(Body.self, from: data).places
}
let places = try await fetchNearby(
endpoint: URL(string: "https://YOUR_DOMAIN/nearby")!,
lat: 52.5201, lon: 13.4049, kind: "pharmacy")
How big a plan you need
Every request that reaches Overspan counts against your monthly quota, and answers from your cache cost nothing. So the number that matters is how often the cache misses, not how often people look things up.
Say 2,000 people a day open your app and each looks something up five times. That is 10,000 lookups a day, about 300,000 a month:
| Answered from your cache | Requests to Overspan a month | Plan that fits |
|---|---|---|
| None (no cache) | 300,000 | Business (1,000,000) |
| Half | 150,000 | Pro (250,000) |
| Four in five | 60,000 | Pro (Indie's 50,000 runs short) |
How much the cache catches depends on how close together your users are: an app for one city does far better than one spread thinly across a country. After a week of real use, the usage dashboard shows your actual figure. All tier limits are on the quickstart, and none of them ever bills as overage.
When Overspan says wait
The function passes these on to the app with the same status and
Retry-After, and never caches them:
-
rate_limited and
concurrency_limit (429): too
many requests this minute, or too many at once. Wait the seconds in
Retry-After. A cache in front makes these rare. -
quota_exceeded (429): the
month's quota is used up. Nothing succeeds until the time in
X-Overspan-Quota-Reset, so the function logs a warning for you. Upgrade in the billing portal or wait for the new month. -
no_backend (503): brief; try
again after
Retry-After.
An app that retries the moment it gets a 429 only collects more of them. Show the user a "busy" state and let the next lookup try again.
Telling your coding agent
If an AI assistant writes your app, paste this into CLAUDE.md,
AGENTS.md, your Cursor rules, or your app builder's knowledge settings, so
it builds the pattern above instead of calling Overpass from the app:
## OpenStreetMap data (Overspan)
- Never call Overpass or Overspan from client code (browser, mobile app, or an
app builder's front end). The app calls our own server function:
GET /nearby?kind=cafe&lat=52.520&lon=13.405
- Only that function knows OVERSPAN_API_KEY, kept in the platform's secret
store, never in the repo or the app bundle. It calls
POST https://api.overspan.dev/api/interpreter with the header
"Authorization: Bearer <key>" and the Overpass QL in a form field named data.
- Follow the recipe for our platform at https://overspan.dev/docs/apps?utm_source=agents-md&utm_medium=rules:
fixed query shapes only, coordinates rounded to 3 decimals, answers cached
for a day, results sorted by distance.
- On 429 or 503, pass Retry-After on to the app and never retry in a loop.
- Show "© OpenStreetMap contributors" wherever the places appear.
Lovable and other builders
Lovable can hold the key for you. A workspace admin or owner adds a custom connector in the workspace settings:
| Field | Value |
|---|---|
| Name | Overspan |
| Authentication | Bearer token (each user pastes their own key when connecting) |
| API base URL | https://api.overspan.dev |
| Test request | GET /api/usage |
| Knowledge file | The block above |
GET /api/usage checks the key without using any quota. Lovable adds the key
on its own servers, so it never appears in your project's code. With any other builder,
put the key in the platform's server-side secrets and use one of the three functions
above.
Credit OpenStreetMap
Every answer carries "attribution": "© OpenStreetMap contributors". Show it
wherever the places appear, linked to
openstreetmap.org/copyright.
The quickstart's attribution section has the exact
wording and what the licence asks of data you publish.
Something here wrong, unclear, or missing? Mail hello@overspan.dev. Documentation gaps are treated as bugs.