# 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.
>
> https://danadresse.dk/en/guides/vue-autocomplete · verified 2026-11-02
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](/en/guides/browser-noegler-domaener) and [Next.js route handler as a proxy](/en/guides/nextjs-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.

```bash id=vite-env run=no
# .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 id=adresse-felt-vue run=no
<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](/en/guides/tilgaengelig-combobox).

## Use the component

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

```vue id=brug-vue run=no
<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:

```bash id=vue-request run=live expect="forslagstekst"
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

- [Browser keys and allowed domains](/en/guides/browser-noegler-domaener)
- [Address field in React](/en/guides/react-autocomplete) — the same logic as a hook.
- [Accessible address combobox](/en/guides/tilgaengelig-combobox)
- Overview: [/en/vue-adresse-autocomplete](/en/vue-adresse-autocomplete).
