Build a store locator

You have a list of your own locations. A spreadsheet of twelve, or a table of four hundred. A customer types their address and you want to show them the closest one and how to get there.

This is a different job from find the nearest place of a kind, which searches the public corpus for pharmacies or cafés. Here the dataset is yours, so the work splits in two: geocode your locations once, then query your own table on every visit.

The code is plain TypeScript. For the React, Next.js, Vue or Nuxt wiring, start from the framework recipes.

Geocode your locations once, not per visit

This is the part people get backwards. Geocoding your twelve branches on every page load is twelve calls per visitor for an answer that has not changed since you opened the store.

Do it once, keep the coordinates, and redo it when the list changes. For a handful of rows, structured search is the right call: you already know which part is the city and which is the province, so say so rather than making the engine guess.

interface Branch {
  id: string;
  name: string;
  address: string;
  city: string;
  region: string;
}
 
const BRANCHES: Branch[] = [
  { id: "yyc-01", name: "Beltline", address: "101 17 Ave SW", city: "Calgary", region: "AB" },
  { id: "yul-01", name: "Centre-ville", address: "1000 rue Sainte-Catherine O", city: "Montréal", region: "QC" },
  { id: "yhz-01", name: "Downtown", address: "5251 Duke St", city: "Halifax", region: "NS" },
];
 
async function locate(branches: Branch[]) {
  const out = [];
  for (const branch of branches) {
    const [hit] = await unmap.geocoder.structured({
      address: branch.address,
      city: branch.city,
      region: branch.region,
      limit: 1,
    });
    out.push({ ...branch, lng: hit?.lng, lat: hit?.lat, quality: hit?.match_type });
  }
  return out;
}
 
const located = await locate(BRANCHES);

Check match_type before you trust a row. A branch whose address resolved to fallback got the street rather than the building, and the pin will sit at one end of it. Those are the rows worth a human look, and there are usually two or three in any real list. For a larger table, batch geocoding does the same thing over a CSV and hands you a review file with exactly those rows in it.

Find the customer

One call, and the same focus idea as anywhere else: if you know roughly where they are shopping, say so. It prefers nearby answers without excluding the far ones.

const [customer] = await unmap.geocoder.search("Bow Valley Square, Calgary", {
  limit: 1,
  focus: [-114.0719, 51.0447],
});
if (!customer) throw new Error("we could not find that address");

Sort your own list by distance

Your locations are in your own table, so this is arithmetic rather than an API call. Great-circle distance is good enough to pick a shortlist.

type Located = { id: string; name: string; lng?: number; lat?: number };
type Pinned = Located & { lng: number; lat: number };
 
function km(a: [number, number], b: [number, number]): number {
  const R = 6371;
  const toRad = (d: number) => (d * Math.PI) / 180;
  const dLat = toRad(b[1] - a[1]);
  const dLng = toRad(b[0] - a[0]);
  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}
 
function shortlistFor(from: [number, number], located: Located[], take = 3) {
  return located
    .filter((b): b is Pinned => b.lng != null && b.lat != null)
    .map((b) => ({ ...b, straightLineKm: km(from, [b.lng, b.lat]) }))
    .sort((a, b) => a.straightLineKm - b.straightLineKm)
    .slice(0, take);
}

Three is a reasonable shortlist. The point of cutting it is the next step: drive time costs a routing call each, and you do not want one per branch.

Rank the shortlist by drive time

Straight-line distance and drive time disagree whenever a river, a rail yard or a one-way system is in the way, and a store locator that sends somebody across a bridge eight kilometres upstream is wrong in a way they notice.

type Pinned = { id: string; name: string; lng: number; lat: number };
 
async function rankByDriveTime(from: [number, number], shortlist: Pinned[]) {
  const legs = await Promise.all(
    shortlist.map(async (branch) => ({
      branch,
      route: await unmap.router.route(from, [branch.lng, branch.lat], { mode: "auto" }),
    })),
  );
  return legs.sort((a, b) => a.route.durationSeconds - b.route.durationSeconds);
}

durationSeconds and distanceMeters come back on the same result, so the label beside each branch ("12 min, 6.4 km") needs no second call.

Draw the way there

The route you already fetched carries the geometry, so showing it costs nothing more.

import type { RouteResult } from "@unmap/routing";
 
function drawRoute(route: RouteResult) {
  unmap.map!.addSource("leg", {
    type: "geojson",
    data: { type: "Feature", properties: {}, geometry: route.geometry },
  });
  unmap.map!.addLayer({
    id: "leg-line",
    type: "line",
    source: "leg",
    layout: { "line-cap": "round", "line-join": "round" },
    paint: { "line-color": "#FF3E9A", "line-width": 3 },
  });
}

What this costs

Geocoding your branches is a one-off: twelve locations is twelve calls, once, plus a few more when you fix the rows that came back as fallbacks. Per visitor it is one geocode for the customer's address plus one routing call per shortlisted branch, so three branches is four calls a visit. Cutting the shortlist from five to three is the difference between six calls and four.

See plans and limits for what a call is and what your plan includes.

Where this gets the wrong answer

  • Stale coordinates. Geocoding once means re-running it when a branch moves. Keep the input address next to the coordinates so you can tell what a row was geocoded from.
  • A branch that never geocoded. The filter above drops locations with no coordinates, which is correct and silent. Log them: a branch missing from the locator is a branch nobody can find.
  • Drive time is a model, not a promise. It is free-flow routing over the road graph, with no live traffic. Treat it as a ranking signal, not an ETA.
  • Canada only. A location outside Canada will not geocode; see coverage.