Put a searched address on a map with MapLibre

About 15 minutes. Updated 11 September 2026.

Address autocomplete that drops a pin on a MapLibre map as soon as an address is picked, with OpenFreeMap tiles so there is no second API key to manage, a pin that follows each pick, and a dark mode that does not leave a bright rectangle on the page.

Every address the API returns already carries its G-NAF coordinates, so putting it on a map is one line after the pick: no geocoding call, no second request. MapLibre is the map, OpenFreeMap serves the tiles, and neither wants a key of its own, so the only credential on the page is the public key for the address search.

01Install MapLibre

One package, which brings its own stylesheet and its own worker. The sample below hands the worker to MapLibre through Vite. With another bundler, serve maplibre-gl-worker.mjs and maplibre-gl-shared.mjs from the same folder and pass the worker's URL to setWorkerUrl: MapLibre looks for it beside its own file, and a bundler moves that file.

shell
npm install maplibre-gl

02The markup

The search input and list from the autocomplete guide, plus a box for the map. Give the box its height in the page: a map that sizes itself makes the page jump when the tiles arrive.

index.htmlhtml
<label for="address">Address</label>
<input id="address" type="search" autocomplete="off" placeholder="145 sydney road">
<ul id="suggestions" hidden></ul>

<!-- A fixed height, set here rather than by the map, so the page does not
     jump when the tiles arrive. -->
<div id="map" style="height: 360px; border-radius: 8px; overflow: hidden"></div>

<script type="module" src="/map-search.js"></script>

03Search, pick, fly

The map opens on Australia. Each result from /v1/addresses is a resolved address with lat and lng, so picking one moves the single pin and flies the map to it. The pin is created once and moved, so the second pick does not leave the first one behind.

map-search.jsjavascript
// map-search.js
// MapLibre 6 is ESM only and exports its classes by name.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// Its worker, bundled by Vite. MapLibre starts the worker from a file beside
// its own, and a bundler moves its own file, so without this the map is an
// empty box and the console says the worker came back as text/html.
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";

maplibregl.setWorkerUrl(workerUrl);

const KEY = "lc_pub_...";                  // a public key, with this site under its allowed sites
const API = "https://api.locio.com.au";

// Australia, zoomed out, until there is somewhere to look.
const map = new maplibregl.Map({
  container: "map",
  style: "https://tiles.openfreemap.org/styles/bright",   // no key, no account, no cap
  center: [134.5, -27.5],
  zoom: 3.4,
  attributionControl: { compact: true },
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");

// One pin, moved rather than replaced, so a second pick does not leave the
// first one behind.
const pin = new maplibregl.Marker({ color: "#18181b" });

const input = document.querySelector("#address");
const list = document.querySelector("#suggestions");
let timer;
let inflight;

input.addEventListener("input", () => {
  clearTimeout(timer);
  const q = input.value.trim();
  if (q.length < 3) {
    list.hidden = true;
    return;
  }
  timer = setTimeout(() => search(q), 200);   // one request per pause, not per keystroke
});

async function search(q) {
  inflight?.abort();
  inflight = new AbortController();
  const url = new URL(`${API}/v1/addresses`);
  url.searchParams.set("q", q);
  url.searchParams.set("limit", "8");
  try {
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${KEY}` },
      signal: inflight.signal,
    });
    if (!res.ok) return;
    const { data } = await res.json();
    list.replaceChildren(
      ...data.map((address) => {
        const item = document.createElement("li");
        const button = document.createElement("button");
        button.type = "button";
        button.textContent = address.formatted;
        button.addEventListener("click", () => show(address));
        item.append(button);
        return item;
      }),
    );
    list.hidden = data.length === 0;
  } catch (err) {
    if (err.name !== "AbortError") console.error(err);
  }
}

function show(address) {
  input.value = address.formatted;
  list.hidden = true;

  // Every result is already resolved, so the coordinates are on it: no
  // geocoding call, no second request.
  const at = [address.lng, address.lat];
  pin.setLngLat(at).setPopup(new maplibregl.Popup({ offset: 24 }).setText(address.formatted)).addTo(map);
  map.flyTo({ center: at, zoom: 17, speed: 1.6 });
}

Store address.id. It is the address's id in its country's register, which in Australia is the G-NAF Address Detail PID, sent under address_detail_pid as well. It is stable across releases for an address that has not changed, where the formatted line is not, so it is the thing to key your own records on rather than the text. The coordinates come with it, so a pin needs no second call.

04Dark mode

OpenFreeMap has no dark style, so a dark page would otherwise carry a bright rectangle. Invert the map's canvas and rotate the hue back; the pin and the popup are ordinary elements and are left alone. This is exactly what the demo on our front page does.

map.csscss
/* Dark mode. OpenFreeMap has no dark style, so invert the canvas and turn
   the hue back, which keeps parks green and water blue. The pin and the
   popup are DOM, not canvas, so they are untouched. */
@media (prefers-color-scheme: dark) {
  #map .maplibregl-canvas {
    filter: invert(1) hue-rotate(180deg);
  }
  #map .maplibregl-popup-content {
    color: #27272a;            /* the popup is white either way */
  }
}

05More than one address

A page showing every store, or every row of a cleaned spreadsheet, wants a layer rather than a marker each: MapLibre draws a GeoJSON source itself and stays smooth at a thousand points.

javascript
// Several addresses at once: a GeoJSON source and a circle layer, which
// the map draws itself, rather than a marker element per address.
map.on("load", () => {
  map.addSource("addresses", {
    type: "geojson",
    data: {
      type: "FeatureCollection",
      features: addresses.map((a) => ({
        type: "Feature",
        geometry: { type: "Point", coordinates: [a.lng, a.lat] },
        properties: { label: a.formatted, id: a.id },
      })),
    },
  });
  map.addLayer({
    id: "addresses",
    type: "circle",
    source: "addresses",
    paint: { "circle-radius": 6, "circle-color": "#18181b", "circle-stroke-width": 2, "circle-stroke-color": "#fff" },
  });
});

Attribution

MapLibre's attribution control, kept in the code above, credits OpenStreetMap and OpenFreeMap, which the map data's licence requires. The address data's own credit, G-NAF under CC BY 4.0, belongs wherever you show what the API returned; the licensing page has the wording.

Related