Build a Store Locator for your website in Under an Hour

A complete mini-project: geocode addresses, render pins on MapLibre, click for details

Every retail app, food delivery platform, and service directory eventually needs a store locator. It sounds like a week-long project but it's usually done in an afternoon — and most of the complexity people assume is present simply isn't.

This guide builds a working store locator from scratch: a list of addresses geocoded to coordinates, rendered as clickable pins on an interactive MapLibre map, with a detail popup on click. No frameworks required. Plain HTML, CSS, and JavaScript.

Here's what we're building:

  • A hardcoded list of store addresses (easy to swap for a database or API)
  • Geocode each address using the Mapsi API
  • Render all locations as pins on a MapLibre map
  • Click a pin → popup shows store name, address, and hours

Total dependencies: MapLibre GL JS (via CDN) and a Mapsi API key.


Step 1: Set Up the Page

Create an index.html file. Pull in MapLibre from CDN — no npm, no bundler.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Store Locator</title>
  <link href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" rel="stylesheet">
  <script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { display: flex; height: 100vh; font-family: sans-serif; }

    #sidebar {
      width: 300px;
      overflow-y: auto;
      border-right: 1px solid #e5e5e5;
      flex-shrink: 0;
    }

    #sidebar h1 {
      font-size: 16px;
      font-weight: 600;
      padding: 16px;
      border-bottom: 1px solid #e5e5e5;
    }

    .store-item {
      padding: 14px 16px;
      border-bottom: 1px solid #f0f0f0;
      cursor: pointer;
      transition: background 0.15s;
    }

    .store-item:hover { background: #f8f8f8; }
    .store-item.active { background: #eff6ff; }

    .store-item h3 { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
    .store-item p  { font-size: 12px; color: #666; line-height: 1.4; }

    #map { flex: 1; }

    .maplibregl-popup-content {
      padding: 12px 16px;
      font-family: sans-serif;
      min-width: 180px;
    }

    .maplibregl-popup-content h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
    .maplibregl-popup-content p  { font-size: 12px; color: #555; line-height: 1.5; }
  </style>
</head>
<body>
  <div id="sidebar">
    <h1>Our stores</h1>
    <div id="store-list"></div>
  </div>
  <div id="map"></div>
  <script src="app.js"></script>
</body>
</html>

Step 2: Get Your API Key

Head over to mapsi.dev and grab your free API key. No credit card required.


Step 3: Define Your Stores

Create app.js. Start with a plain array of store objects. This is the only thing you'd replace with a real API call in production.

const MAPSI_KEY = 'YOUR_API_KEY';

const stores = [
  {
    name: 'Union Square',
    address: '333 Post Street, San Francisco, CA',
    hours: 'Mon–Sat 9am–9pm',
  },
  {
    name: 'Mission District',
    address: '2400 Mission Street, San Francisco, CA',
    hours: 'Mon–Sun 10am–10pm',
  },
  {
    name: 'Castro',
    address: '400 Castro Street, San Francisco, CA',
    hours: 'Mon–Sat 9am–8pm',
  },
  {
    name: 'SOMA',
    address: '1 Clarence Place, San Francisco, CA',
    hours: 'Mon–Sun 9am–9pm',
  },
  {
    name: 'North Beach',
    address: '401 Columbus Avenue, San Francisco, CA',
    hours: 'Mon–Sat 10am–9pm',
  },
];

Swap in your own addresses. The geocoder handles partial addresses, landmark names, and postal codes — it doesn't need to be perfectly formatted.


Step 4: Geocode the Addresses

Write a function that hits the Mapsi geocoding endpoint for each store and attaches coordinates. We run these in parallel with Promise.all so a list of 20 stores geocodes in roughly the same time as one.

The API key is passed via the X-API-Key request header, not in the URL.

async function geocodeStore(store) {
  const url = `https://mapsi.dev/v1/geocode?q=${encodeURIComponent(store.address)}&limit=1`;

  try {
    const res = await fetch(url, {
      headers: { 'X-API-Key': MAPSI_KEY }
    });
    const data = await res.json();

    if (!data.results?.length) {
      console.warn(`Could not geocode: ${store.address}`);
      return null;
    }

    const { lat, lon } = data.results[0].coordinates;
    return { ...store, lat, lon };
  } catch (err) {
    console.warn(`Geocode request failed for: ${store.address}`, err);
    return null;
  }
}

async function geocodeAll(stores) {
  const results = await Promise.all(stores.map(geocodeStore));
  return results.filter(Boolean);
}

Each geocode call costs 1 Mapsi credit. For 5 stores that's 5 credits — well within the 3,000/day free tier. In production, run this once at import time and persist the coordinates to your database so you're not geocoding on every page load.


Step 5: Initialize the Map

The map style URL also requires the X-API-Key header. MapLibre doesn't support headers in a style URL directly, but the transformRequest option lets you inject headers for any request the map makes — including the style fetch and all tile requests.

function initMap() {
  return new maplibregl.Map({
    container: 'map',
    style: 'https://mapsi.dev/v1/tiles/styles?style=streets',
    center: [-122.4194, 37.7749], // San Francisco
    zoom: 12,
    transformRequest: (url) => {
      if (url.startsWith('https://mapsi.dev/')) {
        return { url, headers: { 'X-API-Key': MAPSI_KEY } };
      }
    }
  });
}

transformRequest runs for every network request the map makes. The check for mapsi.dev keeps it scoped — external CDN requests (fonts, sprites) are passed through untouched.


Step 6: Add Pins and Popups

function addMarkers(map, stores) {
  const popup = new maplibregl.Popup({
    closeButton: false,
    offset: 25,
  });

  stores.forEach(store => {
    const marker = new maplibregl.Marker({ color: '#3B6D11' })
      .setLngLat([store.lon, store.lat])
      .addTo(map);

    marker.getElement().addEventListener('click', () => {
      popup
        .setLngLat([store.lon, store.lat])
        .setHTML(`
          <h3>${store.name}</h3>
          <p>${store.address}</p>
          <p>${store.hours}</p>
        `)
        .addTo(map);

      highlightSidebarItem(store.name);
    });
  });
}

A single popup instance is reused across all markers. Each click moves it to the clicked location and updates its content — no stacking, no cleanup needed.


Step 7: Build the Sidebar List

function buildSidebar(map, stores) {
  const list = document.getElementById('store-list');

  stores.forEach(store => {
    const item = document.createElement('div');
    item.className = 'store-item';
    item.dataset.name = store.name;
    item.innerHTML = `
      <h3>${store.name}</h3>
      <p>${store.address}</p>
      <p>${store.hours}</p>
    `;

    item.addEventListener('click', () => {
      map.flyTo({ center: [store.lon, store.lat], zoom: 14 });
      highlightSidebarItem(store.name);
    });

    list.appendChild(item);
  });
}

function highlightSidebarItem(name) {
  document.querySelectorAll('.store-item').forEach(el => {
    el.classList.toggle('active', el.dataset.name === name);
  });
}

Note: If you replace the static stores array with a live API, sanitise the values before inserting them into innerHTML to avoid XSS.


Step 8: Wire It All Together

There's one timing trap to avoid: geocodeAll is async and the map's load event fires independently. If geocoding finishes after the map has already loaded, registering the listener afterwards means the callback never runs and the map renders empty.

The fix is to kick off both in parallel and wait for each to resolve before proceeding:

async function main() {
  const map = initMap();

  // Start both in parallel — whichever finishes first waits for the other
  const loadPromise = new Promise(resolve => map.on('load', resolve));
  const geocoded = await geocodeAll(stores);
  await loadPromise;

  addMarkers(map, geocoded);
  buildSidebar(map, geocoded);
}

main();

Open index.html in your browser. Pins appear at each address, the sidebar lists every store. Click a pin for a popup, click a sidebar item to fly to that location.


What to Build Next

Nearest store detection — use navigator.geolocation to get the user's position, then sort the stores array by distance using a simple Haversine function. No extra API calls needed.

Search filter — add an input above the sidebar list that filters by name or neighbourhood on keypress and re-renders the list.

Load from an API — replace the hardcoded stores array with a fetch call to your backend. The rest of the code is unchanged.

Cluster markers — for 50+ locations, MapLibre's built-in GeoJSON clustering keeps the map readable. Convert your markers to a GeoJSON source and enable cluster: true.


The Full File Structure

store-locator/
├── index.html
└── app.js

Two files. No build step, no framework, no node_modules. Deploy to any static host — Netlify, Vercel, Cloudflare Pages — and it works.