Skip to content

Guide

Build a store locator with MapLibre and geolith

A complete store locator in one HTML file. Geocode your stores once with a script, then let shoppers search an address, see the nearest stores sorted by distance, and view them on a MapLibre map.

By · 20 min · updated

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/search call per submit. Add /v1/autocomplete for 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.

Questions

Quick answers

Why geocode the stores in advance?
Store addresses rarely change. Geocoding them once and storing the coordinates (allowed on every geolith plan) means shoppers only cost one request per search.
Which basemap does the example use?
OpenFreeMap's free Liberty style, which needs no key. geolith's hosted tiles are a sample set today; a full basemap is planned. Any MapLibre style works.
How do I find stores within a radius?
The page computes the distance to every store in the browser. For thousands of stores, filter by a bounding box first or keep the stores in a spatial database.

Geocode your first address in a minute.

100,000 requests a month free. Commercial use with attribution. No card.