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
npm install @mapmetrics/geocoderInstalling before publication
#path: is pnpm syntax and does not work with npm
An earlier version of this page suggested:
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:
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 scriptThen, from your app:
npm install /absolute/path/to/geocoder-sdk/NPMor as a package.json entry:
{
"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:
pnpm add "github:MapMetrics/geocoder-sdk#path:/NPM"Quickstart
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
new MapAtlas({ token: 'YOUR_API_KEY' });
// or, if you rotate/refresh tokens yourself:
new MapAtlas({ getToken: async () => await fetchFreshToken() });| Option | Default | Notes |
|---|---|---|
token | — | Exactly one of token / getToken is required. |
getToken | — | Called fresh on every request — never cached by this package. |
baseUrl | https://gateway.mapmetrics-atlas.net | Override for testing or a self-hosted gateway. |
tier | 'v2' | 'osm' routes to the free, rate-limited OpenStreetMap-only tier. |
debounceMs | 150 | Debounces 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.
npm install react react-dom # if your app doesn't already have themimport { 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:
ordis missing entirely on non-retrievable rows — the key is absent from the JSON, notnull. Two of the fifteen rows forq=amsterdamhave noord, sokey={s.ord}renderskey={undefined}on both, and React falls back to index while warning.idrepeats.q=amsterdam&country=nlreturns 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():
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 noOriginheader. Native/server clients can never satisfy this — see Choosing a key for native apps.OriginNotAllowedError— request'sOriginisn't on the key's allow-list.QuotaExceededError— OSM tier cap exhausted; carriesselfHostUrl.TierUnsupportedError— thrown bycreateSession()on theosmtier and onselfHosted()clients, and byautocomplete()on thev2tier.NetworkError— the request never completed, or the response wasn't parseable JSON.
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:
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: