Address autocomplete field in Angular
Build a standalone Angular component with HttpClient, debounceTime and switchMap that suggests Danish addresses and cancels stale requests.
This guide builds an address field as a standalone component for Angular 17 or later. It uses HttpClient, reactive forms, signals and the RxJS operators debounceTime and switchMap. The template uses the built-in control flow (@if, @for).
The key sits in your Angular bundle, so it must be a domain-locked browser key. See Browser keys and allowed domains. If you would rather call the API from your own server, see Next.js route handler as a proxy: the pattern is the same with an Express or .NET backend.
1. Enable HttpClient
The component fetches data with HttpClient. In a standalone app you register it in app.config.ts:
// app.config.ts import { ApplicationConfig } from "@angular/core"; import { provideHttpClient, withFetch } from "@angular/common/http"; export const appConfig: ApplicationConfig = { providers: [provideHttpClient(withFetch())], };
Put the browser key in environment.ts. Everything in that file ends up in the bundle:
// environments/environment.ts export const environment = { danadresseBrowserKey: "dawa_live_your_browser_key", // locked to your domains };
2. The component
All search logic is one RxJS chain:
debounceTime(200)waits until the user pauses briefly. That saves calls and keeps you under the per-visitor cap.distinctUntilChanged()skips the call if the text is the same as last time.switchMapunsubscribes from the previous HTTP call when a new value arrives. WithHttpClientthat cancels the request, so an old answer never overwrites a new one.catchErrorinsideswitchMapkeeps one error from ending the whole stream. The field keeps working as a plain text input.
import { Component, ElementRef, EventEmitter, Output, ViewChild, computed, inject, signal } from "@angular/core"; import { HttpClient, HttpParams } from "@angular/common/http"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { catchError, debounceTime, distinctUntilChanged, of, switchMap, tap } from "rxjs"; import { environment } from "../environments/environment"; export interface Forslag { type: "vejnavn" | "adgangsadresse" | "adresse"; tekst: string; forslagstekst: string; caretpos: number; data: { id?: string; postnr?: string; postnrnavn?: string; [felt: string]: unknown }; } let naesteId = 0; @Component({ selector: "app-adresse-felt", standalone: true, imports: [ReactiveFormsModule], template: ` <label [for]="uid + '-input'">Address</label> <input #felt [id]="uid + '-input'" [formControl]="q" role="combobox" aria-autocomplete="list" autocomplete="off" [attr.aria-expanded]="vis()" [attr.aria-controls]="uid + '-list'" [attr.aria-activedescendant]="vis() && aktiv() >= 0 ? uid + '-' + aktiv() : null" (input)="aaben.set(true)" (keydown)="tast($event)" (blur)="aaben.set(false)" /> @if (vis()) { <ul [id]="uid + '-list'" role="listbox" aria-label="Address suggestions"> @for (f of forslag(); track f.data.id ?? f.tekst; let i = $index) { <li [id]="uid + '-' + i" role="option" [attr.aria-selected]="i === aktiv()" (mousedown)="$event.preventDefault(); 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> `, }) export class AdresseFeltComponent { @Output() valgt = new EventEmitter<Forslag>(); @ViewChild("felt") felt!: ElementRef<HTMLInputElement>; private http = inject(HttpClient); readonly uid = `adr-${++naesteId}`; readonly q = new FormControl("", { nonNullable: true }); readonly forslag = signal<Forslag[]>([]); readonly aktiv = signal(-1); readonly aaben = signal(false); readonly fejl = signal(false); readonly vis = computed(() => this.aaben() && this.forslag().length > 0); constructor() { this.q.valueChanges .pipe( debounceTime(200), distinctUntilChanged(), switchMap((tekst) => { if (!this.aaben() || tekst.trim().length < 2) return of<Forslag[]>([]); const params = new HttpParams() .set("q", tekst) .set("type", "adresse") .set("per_side", 8); return this.http .get<Forslag[]>("https://api.danadresse.dk/autocomplete", { params, headers: { "X-Api-Key": environment.danadresseBrowserKey }, }) .pipe( tap(() => this.fejl.set(false)), catchError(() => { this.fejl.set(true); return of<Forslag[]>([]); }), ); }), takeUntilDestroyed(), ) .subscribe((liste) => { this.forslag.set(liste); this.aktiv.set(-1); }); } vaelg(f: Forslag): void { if (f.type !== "adresse") { // Street or entrance: insert the text and search on from caretpos. this.q.setValue(f.tekst); requestAnimationFrame(() => this.felt.nativeElement.setSelectionRange(f.caretpos, f.caretpos)); return; } this.aaben.set(false); this.q.setValue(f.forslagstekst, { emitEvent: false }); // no new request this.valgt.emit(f); } tast(e: KeyboardEvent): void { const n = this.forslag().length; if (!this.vis()) return; if (e.key === "ArrowDown") { e.preventDefault(); this.aktiv.update((i) => (i + 1) % n); } else if (e.key === "ArrowUp") { e.preventDefault(); this.aktiv.update((i) => (i <= 0 ? n - 1 : i - 1)); } else if (e.key === "Enter" && this.aktiv() >= 0) { e.preventDefault(); this.vaelg(this.forslag()[this.aktiv()]); } else if (e.key === "Escape") { this.aaben.set(false); } } }
/autocomplete answers in three phases: street name, house number and finally floor and door. That is why the component emits valgt only when the suggestion's type is adresse. A street name is inserted into the field and the search continues.
setValue(…, { emitEvent: false }) on the final choice stops the inserted text from triggering another request.
Why switchMap
mergeMap would let every request finish, and the answers could arrive in the wrong order. concatMap would queue the requests, so the user waits for answers to text they have already typed past. switchMap is the only one of the three that uses just the answer for the latest text.
The component only calls the API when the user types. It therefore also works with Angular SSR: no calls are made during server rendering.
3. Use the component
<app-adresse-felt (valgt)="onAddress($event)" />onAddress(f: Forslag): void {
this.form.patchValue({
address: f.forslagstekst,
addressId: f.data.id,
postcode: f.data.postnr,
city: f.data.postnrnavn,
});
}Store data.id. It is the address UUID, and with /adresser/{id} you can fetch the municipality, coordinates and other fields later.
4. Alternative: the key in an interceptor
If several components use the API, you can set the header in one place. The interceptor adds the key only to calls to api.danadresse.dk, so it is never sent to other servers:
import { HttpInterceptorFn } from "@angular/common/http"; import { environment } from "../environments/environment"; export const danadresseNoegle: HttpInterceptorFn = (req, next) => req.url.startsWith("https://api.danadresse.dk/") ? next(req.clone({ setHeaders: { "X-Api-Key": environment.danadresseBrowserKey } })) : next(req); // app.config.ts: provideHttpClient(withFetch(), withInterceptors([danadresseNoegle]))
5. Test the request
The same query the component sends, from a terminal. If it works here but not in the browser, check the key's allowed domains. For ng serve, localhost must be on the list.
curl -s "https://api.danadresse.dk/autocomplete?q=Vesterbrogade%201&type=adresse&per_side=8" \ -H "X-Api-Key: $DANADRESSE_API_KEY"
A 403 with the code origin_not_allowed means the domain is not on the list. A 429 with rate_limit_per_visitor means one visitor made more than 120 calls in a minute. Error responses carry CORS headers, so HttpErrorResponse has the real status and the JSON body in error (branch on error.code). Retry-After is exposed too: read it with err.headers.get("Retry-After") and wait that many seconds before the next call.
Next steps
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)