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 address 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 = {
  // The id in the register the address came from. In Australia it is the
  // G-NAF Address Detail PID, sent as address_detail_pid as well.
  id: string;
  formatted: string;
  lat: number;
  lng: number;
  mesh_block?: string;          // Australian records only
  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,
    });
    // country_code says which country was searched; note says why the list
    // is empty when empty is the right answer.
    if (res.ok) {
      const body = (await res.json()) as { data: Address[]; country_code?: string; note?: string };
      results.value = body.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.id">
        <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="address_id" :value="picked?.id ?? ''" />
  </div>
</template>

Store 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.

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.id, 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