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.

4 min Level: Intermediate ✓ Code tested

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. 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#adresse-felt-svelte
<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.

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#brug-svelte
<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.

TypeScript#sveltekit-endpoint
// 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.

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:

Shell#svelte-request
curl -s "https://api.danadresse.dk/autocomplete?q=Strandvejen%20100&type=adresse&per_side=8" \
  -H "X-Api-Key: $DANADRESSE_API_KEY"

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 →