Address autocomplete field in Vue 3

Write a Vue 3 component with the Composition API that fetches address suggestions with debounce and cancelled requests, and emits the chosen address.

3 min Level: Intermediate ✓ Code tested

This guide builds a single-file component (SFC) for Vue 3 with <script setup> and TypeScript. The component fetches suggestions from /autocomplete, handles the keyboard and emits a select event with the chosen address. It uses no libraries other than Vue.

The key runs in the browser. It must therefore be a domain-locked browser key, or the calls must go through your own server. Both are covered in Browser keys and allowed domains and Next.js route handler as a proxy. The proxy pattern works the same way in a Nuxt server route.

The key in Vite

A variable prefixed VITE_ is written into the bundle, so anyone can read it. That is fine for a browser key locked to your domains. Never put an unlocked key here.

Shell#vite-env
# .env.local
VITE_DANADRESSE_BROWSER_KEY=dawa_live_your_browser_key

The component

watch with onCleanup is what makes the component reliable. Each time q changes, Vue runs the cleanup from the previous run first. It stops the timer (debounce) and cancels a request that is still in flight. An old, slow answer can then never overwrite a newer one.

Vue#adresse-felt-vue
<script setup lang="ts">
import { computed, ref, watch } from "vue";

type Forslag = {
  type: "vejnavn" | "adgangsadresse" | "adresse";
  tekst: string;
  forslagstekst: string;
  caretpos: number;
  data: Record<string, any>;
};

const emit = defineEmits<{ select: [forslag: Forslag] }>();
const API_KEY = import.meta.env.VITE_DANADRESSE_BROWSER_KEY as string;
const uid = `adr-${Math.random().toString(36).slice(2, 8)}`;

const q = ref("");
const forslag = ref<Forslag[]>([]);
const aktiv = ref(-1);
const aaben = ref(false);
const fejl = ref(false);
const input = ref<HTMLInputElement | null>(null);
const vis = computed(() => aaben.value && forslag.value.length > 0);

watch(q, (vaerdi, _gammel, onCleanup) => {
  if (!aaben.value || vaerdi.trim().length < 2) {
    forslag.value = [];
    return;
  }
  const ctrl = new AbortController();
  const timer = setTimeout(async () => {
    const url = new URL("https://api.danadresse.dk/autocomplete");
    url.searchParams.set("q", vaerdi);
    url.searchParams.set("type", "adresse");
    url.searchParams.set("per_side", "8");
    try {
      const res = await fetch(url, { headers: { "X-Api-Key": API_KEY }, signal: ctrl.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      forslag.value = await res.json();
      fejl.value = false;
      aktiv.value = -1;
    } catch (e) {
      if ((e as Error).name === "AbortError") return;
      forslag.value = [];
      fejl.value = true; // the field keeps working as a plain text input
    }
  }, 200);
  onCleanup(() => {
    clearTimeout(timer);
    ctrl.abort();
  });
});

function vaelg(f: Forslag) {
  if (f.type !== "adresse") {
    // Street or entrance: insert the text and search on from caretpos.
    q.value = f.tekst;
    requestAnimationFrame(() => input.value?.setSelectionRange(f.caretpos, f.caretpos));
    return;
  }
  aaben.value = false;
  q.value = f.forslagstekst;
  emit("select", f);
}

function tast(e: KeyboardEvent) {
  if (!vis.value) return;
  const n = forslag.value.length;
  if (e.key === "ArrowDown") { e.preventDefault(); aktiv.value = (aktiv.value + 1) % n; }
  else if (e.key === "ArrowUp") { e.preventDefault(); aktiv.value = aktiv.value <= 0 ? n - 1 : aktiv.value - 1; }
  else if (e.key === "Enter" && aktiv.value >= 0) { e.preventDefault(); vaelg(forslag.value[aktiv.value]); }
  else if (e.key === "Escape") { aaben.value = false; }
}
</script>

<template>
  <div class="adr">
    <label :for="`${uid}-input`">Address</label>
    <input
      :id="`${uid}-input`"
      ref="input"
      v-model="q"
      role="combobox"
      aria-autocomplete="list"
      :aria-expanded="vis"
      :aria-controls="`${uid}-list`"
      :aria-activedescendant="vis && aktiv >= 0 ? `${uid}-${aktiv}` : undefined"
      autocomplete="off"
      @input="aaben = true"
      @keydown="tast"
      @blur="aaben = false"
    />
    <ul v-if="vis" :id="`${uid}-list`" role="listbox" aria-label="Address suggestions">
      <li
        v-for="(f, i) in forslag"
        :id="`${uid}-${i}`"
        :key="f.data.id ?? f.tekst"
        role="option"
        :aria-selected="i === aktiv"
        @mousedown.prevent="vaelg(f)"
      >
        {{ f.forslagstekst }}
      </li>
    </ul>
    <div role="status" aria-live="polite" class="sr-only">
      {{ fejl ? "Address lookup is unavailable. Type the address manually." : vis ? `${forslag.length} suggestions` : "" }}
    </div>
  </div>
</template>

A few details:

  • @input="aaben = true" opens the list only when the user types. When vaelg itself changes q after a final choice, aaben is false and no extra request is made.
  • @mousedown.prevent selects before the field loses focus. A @click would fire after @blur, when the list is already gone.
  • aria-activedescendant points to the highlighted li, so screen readers announce the suggestion while focus stays in the field. See Accessible address combobox.

Use the component

Listen for select and store both the text and the address id:

Vue#brug-vue
<script setup lang="ts">
import { reactive } from "vue";
import AdresseFelt from "./AdresseFelt.vue";

const order = reactive({ address: "", addressId: "", postcode: "", city: "" });

function onChosen(f: any) {
  order.address = f.forslagstekst;
  order.addressId = f.data.id;
  order.postcode = f.data.postnr;
  order.city = f.data.postnrnavn;
}
</script>

<template>
  <AdresseFelt @select="onChosen" />
</template>

data.id is the address UUID. It does not change when a street is renamed, so it is the right thing to store in your database.

The request the component makes

Run the same query from a terminal when you need to debug. If it works here but not in the browser, the cause is almost always the key's domain list:

Shell#vue-request
curl -s "https://api.danadresse.dk/autocomplete?q=R%C3%A5dhuspladsen%201&type=adresse&per_side=8" \
  -H "X-Api-Key: $DANADRESSE_API_KEY"

Limits in the browser

A browser key uses the account's quota and per-minute limit. On top of that, each visitor has their own cap of 120 calls per minute; above it the API answers 429 with the code rate_limit_per_visitor. With a 200 ms debounce a normal user does not get near it.

Error responses carry CORS headers, so the component sees the real status and JSON body, also on 403 and 500. Retry-After and the X-RateLimit-*, X-RateLimit-Minute-* and X-Quota-* headers are readable from JavaScript too. On a 429 rate_limit_per_visitor, wait res.headers.get("Retry-After") seconds before the next call, show a short message and let the field work as a text input, as the component does for other errors.

Next steps

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →