Address autocomplete in Vue

About 15 minutes. Updated 11 September 2026.

A single file Vue 3 component that adds Australian address autocomplete to a form with the Locio API: script setup, a watched query with a debounce, request cancellation, and the picked address emitted to the parent.

One single file component. The query is a ref, a watcher debounces it and makes the request, and the picked address is emitted upward as a typed object so the parent can store the G-NAF id and use the coordinates.

01The component

The watcher does the work that usually goes wrong: it clears the previous timer and aborts the previous request on every change, so a slow answer for an earlier prefix cannot arrive after a newer one. Results come from /v1/addresses, and each is a resolved address.

src/components/AddressSearch.vuevue
<!-- AddressSearch.vue -->
<script setup lang="ts">
import { ref, watch } from "vue";

type Address = {
  gnaf_pid: string;
  formatted: string;
  lat: number;
  lng: number;
  mesh_block?: string;
  components: Record<string, string>;
};

const KEY = import.meta.env.VITE_LOCIO_KEY as string;   // a public key
const API = "https://api.locio.com.au";

const emit = defineEmits<{ pick: [address: Address] }>();

const query = ref("");
const results = ref<Address[]>([]);
const picked = ref<Address | null>(null);
const busy = ref(false);

let timer: ReturnType<typeof setTimeout> | undefined;
let inflight: AbortController | undefined;

watch(query, (value) => {
  clearTimeout(timer);
  inflight?.abort();
  picked.value = null;

  const q = value.trim();
  if (q.length < 3) {
    results.value = [];
    return;
  }
  // 200ms of quiet before asking: one request per pause, not per keystroke.
  timer = setTimeout(() => search(q), 200);
});

async function search(q: string) {
  inflight = new AbortController();
  busy.value = true;
  try {
    const url = new URL(`${API}/v1/addresses`);
    url.searchParams.set("q", q);
    url.searchParams.set("limit", "8");
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${KEY}` },
      signal: inflight.signal,
    });
    if (res.ok) results.value = ((await res.json()) as { data: Address[] }).data;
  } catch (err) {
    if ((err as Error).name !== "AbortError") console.error(err);
  } finally {
    busy.value = false;
  }
}

function pick(address: Address) {
  picked.value = address;
  query.value = address.formatted;
  results.value = [];
  emit("pick", address);
}
</script>

<template>
  <div>
    <label for="address">Delivery address</label>
    <input id="address" v-model="query" type="search" autocomplete="off" placeholder="145 sydney road" />
    <p v-if="busy">Searching…</p>

    <ul v-if="results.length && !picked" role="listbox">
      <li v-for="address in results" :key="address.gnaf_pid">
        <button type="button" @click="pick(address)">{{ address.formatted }}</button>
      </li>
    </ul>

    <!-- The id is what your form should submit, not the text. -->
    <input type="hidden" name="gnaf_pid" :value="picked?.gnaf_pid ?? ''" />
  </div>
</template>

02Use it

src/App.vuevue
<script setup lang="ts">
import AddressSearch from "./AddressSearch.vue";
</script>

<template>
  <form method="post" action="/checkout">
    <AddressSearch @pick="(address) => console.log(address.gnaf_pid, address.lat, address.lng)" />
    <button type="submit">Continue</button>
  </form>
</template>

What it costs

One unit per request. With the debounce, a completed address is usually three or four requests, so the free 10,000 a month covers a few thousand addresses. Refused requests are free.

Related