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. If you would rather not bundle, the same two files are served from a CDN; the MapLibre site shows the tags.

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
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";

const KEY = "lc_live_...";                 // 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 });
}

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, pid: a.gnaf_pid },
      })),
    },
  });
  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