# 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.

---
title: Build a store locator with MapLibre and geolith
description: 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.
date: '2026-10-22'
tags: [maplibre, store locator, javascript]
minutes: 20
steps:
  - name: Geocode your stores once
    text: Run the Node.js script to turn store addresses into stores.json with coordinates. Results may be stored permanently.
  - name: Create a publishable key
    text: Create a gc_pk_ key at console.geolith.app and allow the origin that serves the page.
  - name: Add the page
    text: Copy index.html. It loads MapLibre GL JS, draws a marker per store and adds a search box.
  - name: Search and sort
    text: The search box calls /v1/search for the shopper's address and sorts stores by haversine distance in the browser.
  - name: Show attribution
    text: The map's attribution control shows the OpenStreetMap and Overture credit.
faqs:
  - q: Why geocode the stores in advance?
    a: Store addresses rarely change. Geocoding them once and storing the coordinates (allowed on every geolith plan) means shoppers only cost one request per search.
  - q: Which basemap does the example use?
    a: 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.
  - q: How do I find stores within a radius?
    a: 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.
---

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):

```js
// 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.

```html
<!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`):

```bash
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](/guides/address-autocomplete-react) or [Vue](/guides/address-autocomplete-vue-nuxt) 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.


Get a free API key: https://console.geolith.app/signup
