Build an address autocomplete

Somebody is filling in a delivery address. You want them to type four characters and pick the right building, and you want the result as parts you can store rather than one string you have to parse later.

live result
the code that produced it
import { Unmap } from "@unmap/sdk";

const unmap = new Unmap({ key: "um_live_..." });

const results = await unmap.geocoder.autocomplete("rue sainte-cath", { limit: 5, lang: "fr" });

The code below is plain TypeScript. For the React, Next.js, Vue or Nuxt wiring, start from the framework recipes, or copy the finished component with npx shadcn add @unmap/geocoder.

Send the whole box, on every keystroke

Autocomplete is built for this. Every word but the last must match a token in full, and the last is treated as the prefix still being typed, so the whole input is the query, not the last word.

const suggestions = await unmap.geocoder.autocomplete("1000 rue sainte-cath", {
  limit: 5,
  lang: "fr",
});

Three characters or fewer answers with places only: cities, provinces and points of interest. Addresses and streets arrive at the fourth character, which is deliberate, because at three characters almost nobody is typing a building.

Do not race yourself

Two things go wrong in every hand-rolled autocomplete, and both produce the same symptom: the list flickers back to an older answer as the user types.

Debounce, so you are not asking on every keystroke. Somewhere around 120 to 200 ms is enough to cut most of the requests without the box feeling slow.

Drop responses you no longer want. A request for mont can come back after one for montreal, and rendering it puts the wrong list on screen. Cancel with an AbortSignal, and keep a generation counter as well, because a cancel that loses the race still has to be ignored.

import type { GeocodeResult } from "@unmap/geocoding";
 
function makeSuggester(render: (results: GeocodeResult[]) => void) {
  let generation = 0;
  let inFlight: AbortController | undefined;
 
  return async function suggest(query: string): Promise<void> {
    inFlight?.abort();
    const controller = new AbortController();
    inFlight = controller;
    const mine = ++generation;
 
    const results = await unmap.geocoder.autocomplete(query, {
      limit: 5,
      signal: controller.signal,
    });
 
    // A late answer for an earlier keystroke is thrown away rather than rendered.
    if (mine !== generation) return;
    render(results);
  };
}

Cancelling does not un-bill the request. Abort stops you waiting for the answer; the server may already have done the work. Debouncing is what reduces the number of calls. Cancelling only keeps the list correct.

Take the parts, not the label

The reason to use a geocoder here rather than a plain text box is what comes back when somebody picks a suggestion. address carries the components; lng and lat are the point.

import type { GeocodeResult } from "@unmap/geocoding";
 
function toFormFields(choice: GeocodeResult) {
  return {
    line1: [choice.address?.housenumber, choice.address?.street].filter(Boolean).join(" "),
    unit: choice.address?.unit ?? "",
    city: choice.address?.city ?? "",
    region: choice.address?.region ?? "",
    postalCode: choice.address?.postcode ?? "",
    lng: choice.lng,
    lat: choice.lat,
  };
}

Every field except lng and lat can be absent, and the honest reason is the data rather than the API: not every source publishes a unit or a postal code, and a locality result has no street at all. Store what came back; do not reconstruct the parts by splitting name.

Keep the keyboard working

An autocomplete that only works with a mouse is broken for a large number of people and fails an accessibility review. The short version: the input keeps focus and owns aria-activedescendant; the list is a listbox and each row an option; up and down move the active option; Enter picks it; Escape closes the list without picking. The registry component (npx shadcn add @unmap/geocoder) does this already, which is the argument for copying it rather than writing it again.

Bias it toward where the user is

If you know roughly where they are, say so. focus moves ranking toward a point without excluding anything, so a Halifax user typing main st sees the Halifax one first and can still reach the one in Vancouver.

const nearMe = await unmap.geocoder.autocomplete("main st", {
  focus: [-63.5752, 44.6488],
  limit: 5,
});

Use bbox instead when you genuinely mean "nothing outside this area". The difference is not cosmetic: focus prefers, bbox excludes.

Where this gets the wrong answer

  • Units are mostly not in the data. A query naming one usually comes back without it, and the result says unit_not_verified in match_reasons. Collect the unit in your own field.
  • Rural routes are not geocodable. RR 2 is a carrier's circuit, not a place. The API strips delivery terms and answers with the community; see coverage.
  • Autocomplete does not correct typos. There is no fuzzy pass here, on purpose: it would cost latency on every keystroke. search retries with a typo-tolerant pass when the exact one comes back empty, so a "no suggestions" state can fall back to it.