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.
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.
<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.
/autocompletefirst answers with street names, then house numbers and finally floor and door. A street name is not an address, sovaelginserts the text and searches again.onselectis called only whentypeisadresse. - No extra calls. After a final choice
aabenis false, so the effect does not fetch suggestions for the text you just inserted. - Screen readers.
role="combobox",aria-activedescendantand 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:
<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.
// 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 plainlet, and$derived(…)a reactive declaration with$:.$effectbecomes a reactive block that depends onqandaaben. Svelte 4 has no cleanup function in a$:block, so keep the timer and theAbortControllerin variables outside the block and clean them up at the start of the next run.$props()becomesexport let apiKey, and the callback proponselectcan become an event withcreateEventDispatcher.oninput,onkeydownandonmousedownare writtenon:input,on:keydownandon:mousedown|preventDefault.
Test the request
The same query the component sends, from a terminal:
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)