Address autocomplete field in Angular

Build a standalone Angular component with HttpClient, debounceTime and switchMap that suggests Danish addresses and cancels stale requests.

4 min Level: Intermediate ✓ Code tested

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:

TypeScript#app-config
// 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:

TypeScript#environment
// 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.
  • switchMap unsubscribes from the previous HTTP call when a new value arrives. With HttpClient that cancels the request, so an old answer never overwrites a new one.
  • catchError inside switchMap keeps one error from ending the whole stream. The field keeps working as a plain text input.
TypeScript#adresse-felt-component
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

HTML#brug-angular
<app-adresse-felt (valgt)="onAddress($event)" />
TypeScript#brug-angular-ts
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:

TypeScript#key-interceptor
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.

Shell#angular-request
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)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →