Address autocomplete in plain JavaScript

About 10 minutes. Updated 11 September 2026.

Add Australian address autocomplete to any HTML form with one script and no framework: a debounced call to the address API as someone types, a list to pick from, and the G-NAF id saved with the form.

This is the smallest useful integration: an input, a list under it, and a hidden field that carries the G-NAF id into whatever handles the form. It works in any page, with any backend, and there is nothing to install.

01The markup

A search input, an empty list for suggestions, and a hidden input for the id. The hidden field is the point of the exercise: store the id against your record and you can read the address back a year later without knowing how it was typed.

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

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

02The script

Wait for 200ms of quiet, then call /v1/addresses with what has been typed so far. Every result is already a resolved address, so picking one needs no second call. An in flight request is abandoned when the next one starts, so a slow answer for "14" cannot land after the fast one for "145 sy".

address-search.jsjavascript
// address-search.js
const KEY = "lc_live_...";                 // a public key, with this site under its allowed sites
const API = "https://api.locio.com.au";

const input = document.querySelector("#address");
const list = document.querySelector("#suggestions");
const hidden = document.querySelector("#gnaf_pid");

let timer;
let inflight;

input.addEventListener("input", () => {
  clearTimeout(timer);
  hidden.value = "";
  const q = input.value.trim();
  if (q.length < 3) {
    list.hidden = true;
    return;
  }
  // 200ms of quiet before asking. Every request is a unit, and one per
  // keystroke buys nothing a slightly later one would not.
  timer = setTimeout(() => search(q), 200);
});

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;              // 402 is quota, 429 is rate; both are worth logging
    const { data } = await res.json();
    render(data);
  } catch (err) {
    if (err.name !== "AbortError") console.error(err);
  }
}

function render(addresses) {
  list.replaceChildren(
    ...addresses.map((address) => {
      const item = document.createElement("li");
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = address.formatted;
      button.addEventListener("click", () => pick(address));
      item.append(button);
      return item;
    }),
  );
  list.hidden = addresses.length === 0;
}

function pick(address) {
  input.value = address.formatted;
  hidden.value = address.gnaf_pid;   // store this, not the text
  list.hidden = true;
  input.dispatchEvent(new CustomEvent("address:picked", { detail: address, bubbles: true }));
}

03Use what was picked

The script fires an event with the whole address on it. Coordinates, the ABS mesh block and every G-NAF component are there without another request.

javascript
document.addEventListener("address:picked", (event) => {
  const { gnaf_pid, lat, lng, components } = event.detail;
  console.log(gnaf_pid, lat, lng, components.postcode);
});

What it costs

One unit per request, and the debounce keeps that to a handful per address. The free tier covers 10,000 a month, which is a few thousand completed addresses. Refused requests are free.

Related