A store locator built with MapLibre GL JS and geolith needs only one API call per shopper search. You geocode your store addresses once with a short Node.js script and keep the coordinates (storing results is allowed on every geolith plan). The page then calls GET https://api.geolith.app/v1/search for the address the shopper types, sorts your stores by straight-line distance, and shows them in a list and on a map.
1. Geocode the stores once
stores.mjs (Node 18+, secret key):
// GEOLITH_API_KEY=YOUR_API_KEY node stores.mjs > stores.json
const stores = [
{ name: 'Colaba', address: 'Colaba Causeway, Mumbai' },
{ name: 'Bandra', address: 'Hill Road, Bandra West, Mumbai' },
{ name: 'Andheri', address: 'Andheri Station Road, Mumbai' },
{ name: 'Powai', address: 'Hiranandani Gardens, Powai, Mumbai' },
{ name: 'Thane', address: 'Ghodbunder Road, Thane' },
];
const out = [];
for (const s of stores) {
const url = new URL('https://api.geolith.app/v1/search');
url.searchParams.set('text', s.address);
url.searchParams.set('size', '1');
url.searchParams.set('boundary.country', 'IN');
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.GEOLITH_API_KEY}` },
});
if (!res.ok) throw new Error(`${s.name}: geolith ${res.status}`);
const { features } = await res.json();
if (!features.length) {
console.error(`No match for ${s.name}; fix the address and re-run`);
continue;
}
const [lon, lat] = features[0].geometry.coordinates;
out.push({ ...s, lat, lon, gid: features[0].properties.gid });
}
console.log(JSON.stringify(out, null, 2));
Check the output once (for example, open each point on a map) and keep stores.json with your code.
2. The page
index.html, served next to stores.json. Replace YOUR_API_KEY with a publishable key (gc_pk_...) whose allowed origins include the page's origin.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Find a store</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.15.0/dist/maplibre-gl.css" />
<style>
body { margin: 0; font: 15px/1.4 system-ui, sans-serif; display: grid; grid-template-columns: 340px 1fr; height: 100vh; }
aside { overflow-y: auto; border-right: 1px solid #ddd; padding: 16px; }
#map { height: 100%; }
form { display: flex; gap: 8px; }
input { flex: 1; padding: 8px; font-size: 15px; }
ol { padding-left: 20px; }
li { margin: 10px 0; cursor: pointer; }
.km { color: #2a7f78; font-variant-numeric: tabular-nums; }
@media (max-width: 700px) { body { grid-template-columns: 1fr; grid-template-rows: auto 50vh; } }
</style>
</head>
<body>
<aside>
<h1>Find a store</h1>
<form id="search">
<input id="q" placeholder="Your address or area" autocomplete="off" required />
<button>Search</button>
</form>
<p id="status"></p>
<ol id="list"></ol>
</aside>
<div id="map"></div>
<script type="module">
import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.15.0/dist/maplibre-gl.mjs';
const API_KEY = 'YOUR_API_KEY'; // publishable key, gc_pk_...
const stores = await (await fetch('./stores.json')).json();
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/liberty',
center: [72.87, 19.07],
zoom: 10,
attributionControl: {
customAttribution: 'Search © OpenStreetMap contributors (ODbL), Overture Maps Foundation',
},
});
map.addControl(new maplibregl.NavigationControl());
const markers = stores.map((s) =>
new maplibregl.Marker({ color: '#2a7f78' })
.setLngLat([s.lon, s.lat])
.setPopup(new maplibregl.Popup().setText(`${s.name}: ${s.address}`))
.addTo(map),
);
let you;
// Great-circle distance in km.
function haversineKm(lat1, lon1, lat2, lon2) {
const rad = Math.PI / 180;
const dLat = (lat2 - lat1) * rad;
const dLon = (lon2 - lon1) * rad;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.sqrt(a));
}
async function geocode(text) {
const url = new URL('https://api.geolith.app/v1/search');
url.searchParams.set('text', text);
url.searchParams.set('size', '1');
url.searchParams.set('boundary.country', 'IN');
url.searchParams.set('api_key', API_KEY);
const res = await fetch(url);
if (!res.ok) throw new Error(`Search failed (${res.status})`);
const { features } = await res.json();
return features[0] ?? null;
}
function render(origin) {
const sorted = stores
.map((s, i) => ({ ...s, i, km: haversineKm(origin.lat, origin.lon, s.lat, s.lon) }))
.sort((a, b) => a.km - b.km);
const list = document.getElementById('list');
list.replaceChildren(
...sorted.map((s) => {
const li = document.createElement('li');
li.innerHTML = `<strong></strong> <span class="km"></span><br><small></small>`;
li.querySelector('strong').textContent = s.name;
li.querySelector('.km').textContent = `${s.km.toFixed(1)} km`;
li.querySelector('small').textContent = s.address;
li.onclick = () => {
map.flyTo({ center: [s.lon, s.lat], zoom: 15 });
markers[s.i].togglePopup();
};
return li;
}),
);
const bounds = new maplibregl.LngLatBounds([origin.lon, origin.lat], [origin.lon, origin.lat]);
sorted.slice(0, 3).forEach((s) => bounds.extend([s.lon, s.lat]));
map.fitBounds(bounds, { padding: 60, maxZoom: 14 });
}
document.getElementById('search').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('status');
status.textContent = 'Searching…';
try {
const place = await geocode(document.getElementById('q').value);
if (!place) {
status.textContent = 'No match. Try a nearby landmark or area.';
return;
}
const [lon, lat] = place.geometry.coordinates;
status.textContent = `Nearest stores to ${place.properties.label}`;
you?.remove();
you = new maplibregl.Marker({ color: '#c2410c' }).setLngLat([lon, lat]).addTo(map);
render({ lat, lon });
} catch (err) {
status.textContent = err.message;
}
});
</script>
</body>
</html>
Serve the folder over HTTP (opening the file directly will not load stores.json):
npx serve .
How it works
- Stores are geocoded once and stored. That is allowed on every geolith plan.
- Search is one
/v1/searchcall per submit. Add/v1/autocompletefor suggestions while typing; see the React or Vue guide. - Distance is the haversine great-circle distance, computed in the browser. It is straight-line distance, not driving distance.
- Basemap: OpenFreeMap's Liberty style needs no key. geolith also serves vector tiles at
/v1/tiles/{set}; the hosted tile set is a sample today and a full basemap is planned.
The example uses Mumbai stores and boundary.country=IN; geolith covers the world, so change the country code (or drop it) for your stores.