# Address autocomplete field in Angular

> Build a standalone Angular component with HttpClient, debounceTime and switchMap that suggests Danish addresses and cancels stale requests.
>
> https://danadresse.dk/en/guides/angular-autocomplete · verified 2026-11-02
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](/en/guides/browser-noegler-domaener). If you would rather call the API from your own server, see [Next.js route handler as a proxy](/en/guides/nextjs-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`:

```ts id=app-config run=no
// 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:

```ts id=environment run=no
// 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.

```ts id=adresse-felt-component run=no
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 id=brug-angular run=no
<app-adresse-felt (valgt)="onAddress($event)" />
```

```ts id=brug-angular-ts run=no
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:

```ts id=key-interceptor run=no
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.

```bash id=angular-request run=live expect="forslagstekst"
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

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