Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

JavaScript / TypeScript SDK

@mapmetrics/geocoder is a TypeScript client for the v2 geocoding API — autocomplete, place retrieval, forward/reverse geocoding, routing, and isochrones. Zero runtime dependencies, ships ESM + CJS + types, and runs anywhere fetch exists: Node 18+, browsers, and edge runtimes like Cloudflare Workers.

Not published yet

npm install @mapmetrics/geocoder will work once the package ships. Until then, build it from a clone — see Installing before publication.

Install

bash
npm install @mapmetrics/geocoder

Installing before publication

#path: is pnpm syntax and does not work with npm

An earlier version of this page suggested:

bash
npm install github:MapMetrics/geocoder-sdk#path:NPM   # ❌ does not work

#path: is a pnpm extension. npm parses everything after # as a git committish, clones the repository root, finds no package.json there (the manifest is in NPM/), and fails:

npm error code ENOENT
npm error enoent Could not read package.json: ENOENT: no such file or
npm error directory, open '…/_cacache/tmp/git-cloneXXXXXX/package.json'

Yarn Classic and npm behave the same way. Only pnpm understands #path:.

The package is a monorepo subdirectory and its dist/ is not committed, so consume it as a built local dependency:

bash
git clone https://github.com/MapMetrics/geocoder-sdk
cd geocoder-sdk/NPM
npm install
npm run build          # required — dist/ is gitignored and there is no prepare script

Then, from your app:

bash
npm install /absolute/path/to/geocoder-sdk/NPM

or as a package.json entry:

json
{
  "dependencies": {
    "@mapmetrics/geocoder": "file:../geocoder-sdk/NPM"
  }
}

The npm run build step is not optional. package.json lists dist under files, but there is no prepare/prepack script, so nothing builds automatically on install — skip it and you get a package whose entry points resolve to files that do not exist.

If your project uses pnpm, the one-liner does work there:

bash
pnpm add "github:MapMetrics/geocoder-sdk#path:/NPM"

Quickstart

ts
import { MapAtlas } from '@mapmetrics/geocoder';

const mapatlas = new MapAtlas({ token: 'YOUR_API_KEY' });

// One session covers a whole search — every keystroke, then the pick.
const session = mapatlas.geocoding.createSession();

// As the user types. Debounced inside the client (see below); suggestions
// carry no coordinates.
const results = await session.suggest('Nieuwezijds');

// When they pick one, hand back the suggestion object — not an id.
const place = await session.retrieve(results[0]);
console.log(place.latitude, place.longitude);

// Resolving several at once is both fewer round trips and cheaper billing —
// see Sessions & Billing.
const places = await session.retrieveBatch(results.slice(0, 3));

// One-shot lookups, when there's no user typing to debounce. Both return GeoJSON.
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147', { country: 'nl' });
await mapatlas.geocoding.reverse(52.37, 4.89);

Constructor options

ts
new MapAtlas({ token: 'YOUR_API_KEY' });
// or, if you rotate/refresh tokens yourself:
new MapAtlas({ getToken: async () => await fetchFreshToken() });
OptionDefaultNotes
tokenExactly one of token / getToken is required.
getTokenCalled fresh on every request — never cached by this package.
baseUrlhttps://gateway.mapmetrics-atlas.netOverride for testing or a self-hosted gateway.
tier'v2''osm' routes to the free, rate-limited OpenStreetMap-only tier.
debounceMs150Debounces rapid suggest() calls. 0 disables.

The reactive layer: useAutocomplete

@mapmetrics/geocoder/react is a separate subpath export with a headless useAutocomplete hook — it owns the Session (and therefore billing), debouncing, request cancellation, and error handling. Importing the core @mapmetrics/geocoder entry never pulls React into your bundle; react (>=18) is an optional peer dependency, only needed if you import this subpath.

bash
npm install react react-dom   # if your app doesn't already have them
tsx
import { useMemo, useState } from 'react';
import { MapAtlas } from '@mapmetrics/geocoder';
import { useAutocomplete } from '@mapmetrics/geocoder/react';
import type { Suggestion } from '@mapmetrics/geocoder';

function AddressField() {
  const client = useMemo(() => new MapAtlas({ token: 'YOUR_API_KEY' }), []);
  const [selecting, setSelecting] = useState(false);

  const {
    query, setQuery,       // controlled input value
    suggestions,           // Suggestion[] — updates as the user types
    isLoading,              // a request is in flight
    error,                  // typed MapAtlasError | null
    select,                 // (s: Suggestion) => Promise<RetrievedPlace>
    selected,                // RetrievedPlace | null — last successful selection
    reset,                   // clear query, suggestions, error and selection
  } = useAutocomplete({ client, country: 'nl', minLength: 2 });

  async function handleSelect(s: Suggestion) {
    setSelecting(true);
    try {
      const place = await select(s);
      console.log(place.latitude, place.longitude);
    } catch {
      // already surfaced via `error` below
    } finally {
      setSelecting(false);
    }
  }

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search an address…"
      />
      {isLoading && <span>Searching…</span>}
      {error && <span role="alert">{error.name}: {error.message}</span>}
      <ul>
        {/* Key on index, not on s.ord or s.id — see the warning below. */}
        {suggestions.map((s, i) => (
          <li key={i}>
            <button type="button" disabled={selecting} onClick={() => handleSelect(s)}>
              {s.placeName ?? s.text}
            </button>
          </li>
        ))}
      </ul>
      {selected && (
        <p>
          {selected.latitude}, {selected.longitude}
          <button type="button" onClick={reset}>Clear</button>
        </p>
      )}
    </div>
  );
}

Options: client (required), country, proximity, minLength (default 2 — below it, no request fires and suggestions is cleared), debounceMs (extra debounce stacked on top of the client's own — leave unset).

Where the debounce actually lives

The hook does not debounce on the v2 tier. Coalescing happens one layer down, in Session.suggest() inside the client — configured by new MapAtlas({ debounceMs }), default 150.

The hook's own debounceMs option is an additional timer on top of that. Leave it unset. Setting it to, say, 300 gives you 300 ms of extra lag without reducing request count, because the client is already coalescing underneath.

One exception: the osm tier has no Session, so nothing downstream coalesces. There the hook adds a single timer itself, reusing client.geocoding.debounceMs — so the same 150 ms default applies and you still do not need to configure anything.

Never key a React list on ord or id

Neither is a usable React key:

  • ord is missing entirely on non-retrievable rows — the key is absent from the JSON, not null. Two of the fifteen rows for q=amsterdam have no ord, so key={s.ord} renders key={undefined} on both, and React falls back to index while warning.
  • id repeats. q=amsterdam&country=nl returns two rows with "id": "place.0". Duplicate React keys silently swap component state between rows.

Use the array index, or the uniquifyRows helper from @mapmetrics/geocoder/react — see Search UI.

Session lifecycle matches the core Session: one session per client, reused across every keystroke; select() retrieves and closes it, and the next setQuery() reopens one automatically. suggestCount and isOpen on the hook's return value mirror Session.suggestCount / Session.isOpen.

Safety guarantees: out-of-order responses are discarded (a slow response for an earlier keystroke never overwrites a later one); no setState fires after unmount; React 18 <StrictMode>-safe (no duplicate session, no duplicate request on double-invoked effects).

Routing & isochrones

mapatlas.routing and the top-level mapatlas.isochrone() wrap the gateway's Valhalla-backed endpoints — directions, matrix, map matching, and route optimization. costing is a strict union ('auto' | 'bicycle' | 'bus' | 'truck' | 'taxi' | 'motor_scooter' | 'pedestrian' | 'bikeshare'), not a bare string, and this client always hits /optimization/, not /optimize/ (the latter 404s). See the README for the full request/response shapes.

The OSM tier

tier: 'osm' talks to the free, OpenStreetMap-only endpoints (/osm-geocode/, /osm-reverse/, /osm-autocomplete/) via the osm-geocode scope. It has no session/retrieve flow — search(), reverse(), and autocomplete() are the only three operations, results already carry coordinates, and createSession() throws TierUnsupportedError. Rate-limited to 10,000 requests/key/day plus a global monthly cap; hitting it throws QuotaExceededError.

Self-hosting

The free tier's engine is open source: MapMetrics/atlas-osm-geocoder, deployable on Cloudflare Workers with a prebuilt planet bundle. Point this package at your own instance with MapAtlas.selfHosted():

ts
const mapatlas = MapAtlas.selfHosted({ baseUrl: 'https://my-worker.workers.dev' });

await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147');
await mapatlas.geocoding.autocomplete('Nieuwezijds');
await mapatlas.geocoding.reverse(52.37, 4.89);

A self-hosted instance takes no token and uses different paths (/search, /reverse, /autocomplete — no /osm- prefix, no token parameter); selfHosted() never sends a credential to it, even if you pass one by mistake. createSession() and session-based retrieval are unavailable on a self-hosted client, same as on tier: 'osm' — there's no session concept on this engine.

Errors

All errors extend MapAtlasError:

  • TokenNotFoundError — key was never provisioned.
  • TokenInactiveError — key exists but is deactivated.
  • ScopeError — key lacks the scope this operation requires.
  • OriginRequiredError — key is origin-restricted, request sent no Origin header. Native/server clients can never satisfy this — see Choosing a key for native apps.
  • OriginNotAllowedError — request's Origin isn't on the key's allow-list.
  • QuotaExceededError — OSM tier cap exhausted; carries selfHostUrl.
  • TierUnsupportedError — thrown by createSession() on the osm tier and on selfHosted() clients, and by autocomplete() on the v2 tier.
  • NetworkError — the request never completed, or the response wasn't parseable JSON.
ts
import { MapAtlasError, ScopeError } from '@mapmetrics/geocoder';

try {
  await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147', { country: 'nl' });
} catch (e) {
  if (e instanceof ScopeError) {
    // ...
  } else if (e instanceof MapAtlasError) {
    // catches every other documented failure mode
  } else {
    throw e;
  }
}

Prefer e.code / e.name (plain strings) over instanceof if your app might load this package as both ESM and CJS in the same dependency tree — that produces two distinct class objects for the same error, and instanceof won't match across them.

Not every suggestion can be resolved

The gateway returns some rows — injected locality entries, such as the city itself when you type "Amsterdam" — with no retrieve handle. They are still returned so you can show them in a list, but they cannot be turned into coordinates.

suggestion.isRetrievable tells you which is which. In the raw gateway JSON the ord key is absent on those rows rather than null; this package normalises it to null (never NaN) when decoding, so you can test it directly:

ts
const results = await session.suggest('Amsterdam', { country: 'nl' });
const usable = results.filter((s) => s.isRetrievable);

Calling retrieve() on a non-retrievable suggestion throws NotRetrievableError. retrieveBatch() rejects the whole call if any item is non-retrievable, rather than silently returning fewer places than you asked for. All four SDKs behave identically here.

Endpoint reference

This SDK is a typed wrapper — for the full parameter list, response shape, and gateway-level gotchas each call is protecting you from, see: