Build a neighborhood cafe finder
You want coffee. Good coffee, ideally somewhere with outdoor seating and within walking distance. You don't want to scroll through Google Maps and mentally filter fifty results. You want to type in your address and see what's close, sorted by how long it takes to get there on foot.
We're going to build that. A single HTML page, maybe 50 lines of JavaScript, backed by Plaza's API. By the end you'll have a working map that geocodes an address, finds cafes nearby, calculates walking time to each one, and plots them with route info.
What we're using
- Geocoding to turn an address into coordinates
- Overpass QL to find cafes with specific tags
- Routing to calculate walking time to each result
- MapLibre GL JS to display everything on a map
The full page
Here's the entire thing. Save it as cafe-finder.html, swap in your API key, and open it in a browser.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cafe finder</title>
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" />
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
#map { position: absolute; inset: 0; }
#controls {
position: absolute; top: 12px; left: 12px; z-index: 1;
background: white; padding: 12px 16px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); max-width: 340px;
}
input { width: 100%; padding: 8px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; }
button { margin-top: 8px; padding: 8px 16px; cursor: pointer; border: none; background: #2563eb; color: white; border-radius: 4px; }
#results { margin-top: 12px; font-size: 14px; max-height: 300px; overflow-y: auto; }
.cafe-item { padding: 6px 0; border-bottom: 1px solid #eee; cursor: pointer; }
.cafe-item:hover { background: #f5f5f5; }
</style>
</head>
<body>
<div id="map"></div>
<div id="controls">
<input id="address" type="text" placeholder="Your address, e.g. 300 Ivy St, San Francisco" />
<button onclick="findCafes()">Find cafes</button>
<div id="results"></div>
</div>
<script>
const API_KEY = "pk_live_YOUR_KEY";
const BASE = "https://plaza.fyi/api/v1";
const headers = { "x-api-key": API_KEY };
const map = new maplibregl.Map({
container: "map",
style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
center: [-122.42, 37.77],
zoom: 14,
});
async function plaza(path, opts = {}) {
const url = opts.body ? `${BASE}${path}` : `${BASE}${path}`;
const resp = await fetch(url, {
method: opts.body ? "POST" : "GET",
headers: { ...headers, ...(opts.body ? { "Content-Type": "application/json" } : {}) },
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
return resp.json();
}
async function findCafes() {
const addr = document.getElementById("address").value;
if (!addr) return;
document.getElementById("results").textContent = "Searching...";
// Step 1: geocode the address
const geo = await plaza(`/geocode?q=${encodeURIComponent(addr)}`);
if (!geo.results?.length) {
document.getElementById("results").textContent = "Could not find that address.";
return;
}
const { lat, lng } = geo.results[0];
// Step 2: find cafes with outdoor seating within 800m using Overpass QL
const overpass = await plaza("/overpass", {
body: {
data: `[out:json];(node[amenity=cafe][outdoor_seating=yes](around:800,${lat},${lng});node[amenity=cafe][cuisine=coffee](around:800,${lat},${lng}););out;`,
},
});
const cafes = overpass.features || [];
if (!cafes.length) {
document.getElementById("results").textContent = "No cafes found nearby.";
return;
}
// Step 3: calculate walking time to each cafe
const withRoutes = await Promise.all(
cafes.slice(0, 10).map(async (cafe) => {
const [cLng, cLat] = cafe.geometry.coordinates;
const route = await plaza("/route", {
body: {
origin: { lat, lng },
destination: { lat: cLat, lng: cLng },
mode: "foot",
},
});
return { cafe, route, walkMin: Math.round((route.properties?.duration_s || 0) / 60) };
})
);
// Sort by walking time
withRoutes.sort((a, b) => a.walkMin - b.walkMin);
// Clear old markers and layers
if (map.getSource("cafes")) {
map.removeLayer("cafe-dots");
map.removeSource("cafes");
}
// Plot cafes on the map
const fc = { type: "FeatureCollection", features: withRoutes.map((r) => r.cafe) };
map.addSource("cafes", { type: "geojson", data: fc });
map.addLayer({
id: "cafe-dots",
type: "circle",
source: "cafes",
paint: { "circle-radius": 7, "circle-color": "#d97706", "circle-stroke-width": 2, "circle-stroke-color": "#fff" },
});
// Add origin marker
new maplibregl.Marker({ color: "#2563eb" }).setLngLat([lng, lat]).addTo(map);
// Fit map to show everything
const bounds = new maplibregl.LngLatBounds();
bounds.extend([lng, lat]);
withRoutes.forEach((r) => bounds.extend(r.cafe.geometry.coordinates));
map.fitBounds(bounds, { padding: 60 });
// Render results list
const resultsDiv = document.getElementById("results");
resultsDiv.innerHTML = withRoutes
.map((r) => {
const name = r.cafe.properties?.tags?.name || "Unnamed cafe";
return `<div class="cafe-item" onclick="map.flyTo({center:[${r.cafe.geometry.coordinates}],zoom:16})">${name} -- ${r.walkMin} min walk</div>`;
})
.join("");
}
</script>
</body>
</html>
How it works, piece by piece
Geocoding the address
The first call turns whatever the user typed into coordinates:
GET /api/v1/geocode?q=300+Ivy+St,+San+Francisco
We take the first result's lat and lng and use those as our center point. If you're building something for production, you'd want to show a disambiguation list when multiple results come back. For a quick tool like this, the top result is usually right.
Finding cafes with Overpass QL
This is the fun part. Instead of just searching for "cafe," we use Overpass QL to ask for cafes that match specific tags:
(
node[amenity=cafe][outdoor_seating=yes](around:800,LAT,LNG);
node[amenity=cafe][cuisine=coffee](around:800,LAT,LNG);
);
That's a union of two queries: cafes with outdoor seating, and cafes tagged as coffee-focused. The around:800 filter limits results to 800 meters from our geocoded point. You could tighten that to 400m or loosen it to 1500m depending on how far you're willing to walk.
The Overpass QL approach is more precise than a generic "cafe" search. OSM data has rich tags -- you could filter by internet_access=wlan, wheelchair=yes, or opening_hours if you wanted.
Calculating walking routes
For each cafe (capped at 10 to keep things responsive), we hit the routing endpoint:
POST /api/v1/route
{ "origin": {"lat": ..., "lng": ...}, "destination": {"lat": ..., "lng": ...}, "mode": "foot" }
This gives us actual walking time, not just straight-line distance. Two cafes might be the same distance as the crow flies, but one could be across a highway with no pedestrian crossing. Walking time is what actually matters when you want coffee.
Sorting and display
Sort by duration_s, shortest first. The sidebar shows each cafe name and walking time. Click one and the map flies to it.
What to add next
You could filter by opening hours -- OSM has opening_hours tags on a lot of cafes, though the syntax for parsing them is its own mini-language. Libraries exist for it if you want to go down that road.
The routing response includes a LineString geometry for each route. Draw it on the map when someone clicks a cafe and they can see the actual walking path, not just a dot.
For the address input, /api/v1/geocode/autocomplete gives you typeahead suggestions. And if you need walking times to more than 10 cafes, swap the individual route calls for a single matrix request -- one API call instead of ten.
About 100 lines of code, no build step, no framework. Just an HTML file and Plaza.