# Address autocomplete field in Svelte 5

> Build a Svelte 5 component with runes that fetches address suggestions with debounce and AbortController, and move the key to a SvelteKit endpoint if you want.
>
> https://danadresse.dk/en/guides/svelte-autocomplete · verified 2026-11-02
This guide uses **Svelte 5** and runes (`$state`, `$derived`, `$effect`, `$props`). The component works in a plain Vite app and in SvelteKit. On Svelte 4 the logic is the same, but you write `let` and `$:` instead of runes, and `on:input` instead of `oninput`.

The key is sent from the browser. Use a domain-locked browser key, see [Browser keys and allowed domains](/en/guides/browser-noegler-domaener). To keep any key out of the browser, see the section on the SvelteKit endpoint at the end.

## The component

`$effect` runs again whenever a value it reads changes. The function the effect returns runs before the next run. We use it to stop the debounce timer and cancel a request still in flight. Note that `q` and `aaben` must be read directly in the effect, not inside `setTimeout`: Svelte only tracks values read synchronously.

```svelte id=adresse-felt-svelte run=no
<script lang="ts">
  type Forslag = {
    type: "vejnavn" | "adgangsadresse" | "adresse";
    tekst: string;
    forslagstekst: string;
    caretpos: number;
    data: Record<string, any>;
  };

  let { apiKey, onselect }: { apiKey: string; onselect: (f: Forslag) => void } = $props();

  const uid = `adr-${Math.random().toString(36).slice(2, 8)}`;
  let q = $state("");
  let aaben = $state(false);
  let forslag = $state<Forslag[]>([]);
  let aktiv = $state(-1);
  let fejl = $state(false);
  let input: HTMLInputElement;
  let vis = $derived(aaben && forslag.length > 0);

  $effect(() => {
    const tekst = q;            // read synchronously → the effect tracks q
    if (!aaben || tekst.trim().length < 2) {
      forslag = [];
      return;
    }
    const ctrl = new AbortController();
    const timer = setTimeout(async () => {
      const url = new URL("https://api.danadresse.dk/autocomplete");
      url.searchParams.set("q", tekst);
      url.searchParams.set("type", "adresse");
      url.searchParams.set("per_side", "8");
      try {
        const res = await fetch(url, { headers: { "X-Api-Key": apiKey }, signal: ctrl.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        forslag = await res.json();
        fejl = false;
        aktiv = -1;
      } catch (e) {
        if ((e as Error).name === "AbortError") return;
        forslag = [];
        fejl = true;
      }
    }, 200);
    return () => {
      clearTimeout(timer);
      ctrl.abort();
    };
  });

  function vaelg(f: Forslag) {
    if (f.type !== "adresse") {
      q = f.tekst; // street/entrance: search on
      requestAnimationFrame(() => input.setSelectionRange(f.caretpos, f.caretpos));
      return;
    }
    aaben = false;
    q = f.forslagstekst;
    onselect(f);
  }

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

<div class="adr">
  <label for="{uid}-input">Address</label>
  <input
    id="{uid}-input"
    bind:this={input}
    bind:value={q}
    oninput={() => (aaben = true)}
    onkeydown={tast}
    onblur={() => (aaben = false)}
    role="combobox"
    aria-autocomplete="list"
    aria-expanded={vis}
    aria-controls="{uid}-list"
    aria-activedescendant={vis && aktiv >= 0 ? `${uid}-${aktiv}` : undefined}
    autocomplete="off"
  />
  {#if vis}
    <ul id="{uid}-list" role="listbox" aria-label="Address suggestions">
      {#each forslag as f, i (f.data.id ?? f.tekst)}
        <li
          id="{uid}-{i}"
          role="option"
          aria-selected={i === aktiv}
          onmousedown={(e) => { e.preventDefault(); vaelg(f); }}
        >
          {f.forslagstekst}
        </li>
      {/each}
    </ul>
  {/if}
  <div role="status" aria-live="polite" class="sr-only">
    {fejl ? "Address lookup is unavailable. Type the address manually." : vis ? `${forslag.length} suggestions` : ""}
  </div>
</div>
```

Three things to note:

- **Drill-down.** `/autocomplete` first answers with street names, then house numbers and finally floor and door. A street name is not an address, so `vaelg` inserts the text and searches again. `onselect` is called only when `type` is `adresse`.
- **No extra calls.** After a final choice `aaben` is false, so the effect does not fetch suggestions for the text you just inserted.
- **Screen readers.** `role="combobox"`, `aria-activedescendant` and the live region follow the ARIA 1.2 pattern. See [Accessible address combobox](/en/guides/tilgaengelig-combobox).

## Use the component

In SvelteKit, put the browser key in a `PUBLIC_` variable. It ends up in the bundle, so it must be locked to your domains:

```svelte id=brug-svelte run=no
<script lang="ts">
  import { PUBLIC_DANADRESSE_BROWSER_KEY } from "$env/static/public";
  import AdresseFelt from "$lib/AdresseFelt.svelte";

  let addressId = $state("");
  let postcode = $state("");
  let city = $state("");
</script>

<AdresseFelt
  apiKey={PUBLIC_DANADRESSE_BROWSER_KEY}
  onselect={(f) => {
    addressId = f.data.id;
    postcode = f.data.postnr;
    city = f.data.postnrnavn;
  }}
/>
<input type="hidden" name="address_id" value={addressId} />
```

Store `data.id`. It is the address's permanent UUID, and with it you can look the address up again with `/adresser/{id}`.

## No key in the browser: a SvelteKit endpoint

If you do not want a key in the browser, let a server route call the API. The key lives in `$env/static/private` and never reaches the client. The component then calls `/api/adresse?q=…` without a header.

```ts id=sveltekit-endpoint run=no
// src/routes/api/adresse/+server.ts
import { DANADRESSE_API_KEY } from "$env/static/private";
import type { RequestHandler } from "./$types";

export const GET: RequestHandler = async ({ url, fetch }) => {
  const q = (url.searchParams.get("q") ?? "").slice(0, 120);
  const upstream = new URL("https://api.danadresse.dk/autocomplete");
  upstream.searchParams.set("q", q);
  upstream.searchParams.set("type", "adresse");
  upstream.searchParams.set("per_side", "8");

  const res = await fetch(upstream, { headers: { "X-Api-Key": DANADRESSE_API_KEY } });
  return new Response(await res.text(), {
    status: res.status,
    headers: {
      "Content-Type": "application/json",
      "Cache-Control": res.ok ? "public, max-age=60, s-maxage=300" : "no-store",
    },
  });
};
```

Here all visitors share your server's key and its per-minute limit. The per-visitor cap that browser keys have does not apply. Add your own per-IP limit in your app if the page is public. The details, including passing rate-limit headers through, are in [Next.js route handler as a proxy](/en/guides/nextjs-proxy).

## On Svelte 4

The logic carries over directly. Only the syntax differs:

- `$state(…)` becomes a plain `let`, and `$derived(…)` a reactive declaration with `$:`.
- `$effect` becomes a reactive block that depends on `q` and `aaben`. Svelte 4 has no cleanup function in a `$:` block, so keep the timer and the `AbortController` in variables outside the block and clean them up at the start of the next run.
- `$props()` becomes `export let apiKey`, and the callback prop `onselect` can become an event with `createEventDispatcher`.
- `oninput`, `onkeydown` and `onmousedown` are written `on:input`, `on:keydown` and `on:mousedown|preventDefault`.

## Test the request

The same query the component sends, from a terminal:

```bash id=svelte-request run=live expect="forslagstekst"
curl -s "https://api.danadresse.dk/autocomplete?q=Strandvejen%20100&type=adresse&per_side=8" \
  -H "X-Api-Key: $DANADRESSE_API_KEY"
```

## Next steps

- [Browser keys and allowed domains](/en/guides/browser-noegler-domaener)
- [Next.js route handler as a proxy](/en/guides/nextjs-proxy)
- [Accessible address combobox](/en/guides/tilgaengelig-combobox)
