Adress-Autovervollständigung

Von einem leeren Eingabefeld zu einer validierten, gespeicherten Adresse

Diese Anleitung baut den Adressschritt eines Checkouts: Der Nutzer tippt, wählt eine Stadt, und die Felder für Land, Bundesland und Postleitzahl füllen sich selbst und werden validiert. Rund 60 Zeilen JavaScript.

Drei Endpunkte erledigen die Arbeit:

  1. /v1/places/autocomplete — Vorschläge während der Eingabe
  2. /v1/places/details — der vollständige Datensatz des Gewählten
  3. /v1/places/validate — die abschließende Prüfung vor dem Speichern

Zuerst der Sitzungstoken

Eine Autovervollständigung sendet eine Anfrage pro Tastendruck. Damit die Kosten vorhersehbar bleiben, erzeugen Sie einen sessiontoken, sobald das Feld den Fokus erhält, und verwenden ihn für alle Anfragen dieses Feldes weiter — einschließlich des abschließenden details-Aufrufs. Das gesamte Feld kostet 1 Token, wie viel der Nutzer auch tippt.

let session = crypto.randomUUID();

input.addEventListener('focus', () => {
  session = crypto.randomUUID(); // eine Sitzung pro Adresse, die der Nutzer eingibt
});

Sitzungen laufen 3 Minuten. Lassen Sie den Token weg, kostet jede Anfrage 1 Token.

Reines JavaScript

<input id="city" placeholder="Geben Sie Ihre Stadt ein..." autocomplete="off" />
<ul id="suggestions"></ul>

<input id="country" readonly />
<input id="state" readonly />
<input id="zip" placeholder="Postleitzahl" />
<p id="zip-error"></p>
const API_KEY = 'ihr-api-schluessel';
const BASE = 'https://api.countrydataapi.com/v1/places';

const input = document.getElementById('city');
const list = document.getElementById('suggestions');

let session = crypto.randomUUID();
let timer;
let postal = null;

input.addEventListener('focus', () => { session = crypto.randomUUID(); });

// Debounce: eine Anfrage pro Pause, nicht pro Tastendruck.
input.addEventListener('input', () => {
  clearTimeout(timer);
  const q = input.value.trim();
  if (q.length < 2) { list.innerHTML = ''; return; }

  timer = setTimeout(() => search(q), 150);
});

async function search(q) {
  const params = new URLSearchParams({
    apikey: API_KEY,
    q,
    types: 'city',
    limit: '5',
    lang: 'de',
    sessiontoken: session,
  });

  const response = await fetch(`${BASE}/autocomplete?${params}`);
  const { suggestions } = await response.json();

  list.innerHTML = '';
  suggestions.forEach((suggestion) => {
    const item = document.createElement('li');
    item.textContent = suggestion.description; // "Madrid, Autonome Gemeinschaft Madrid, Spanien"
    item.onclick = () => choose(suggestion);
    list.appendChild(item);
  });
}

async function choose(suggestion) {
  list.innerHTML = '';
  input.value = suggestion.name;

  const params = new URLSearchParams({
    apikey: API_KEY,
    id: suggestion.id,
    type: suggestion.type,
    lang: 'de',
    sessiontoken: session, // schließt die Sitzung: dieser Aufruf ist kostenlos
  });

  const response = await fetch(`${BASE}/details?${params}`);
  const { place } = await response.json();

  document.getElementById('country').value = place.components.country?.name ?? '';
  document.getElementById('state').value = place.components.state?.name ?? '';

  // Die Postleitzahlregeln des Landes, um das nächste Feld im Browser zu validieren.
  postal = place.postal;
  document.getElementById('zip').placeholder = postal.example ?? 'Postleitzahl';
}

document.getElementById('zip').addEventListener('blur', (event) => {
  const error = document.getElementById('zip-error');
  if (!postal?.regex) { error.textContent = ''; return; }

  const ok = new RegExp(postal.regex).test(event.target.value.trim());
  error.textContent = ok ? '' : `Erwartetes Format: ${postal.example ?? postal.format}`;
});

Das ist die ganze Interaktion. Das Feld description ist bereits für ein Dropdown formatiert, es sind also keine Zeichenketten zusammenzusetzen.

React

import { useEffect, useRef, useState } from 'react';

const API_KEY = 'ihr-api-schluessel';
const BASE = 'https://api.countrydataapi.com/v1/places';

export function useAddressAutocomplete() {
  const [query, setQuery] = useState('');
  const [suggestions, setSuggestions] = useState([]);
  const [address, setAddress] = useState(null);
  const session = useRef(crypto.randomUUID());

  useEffect(() => {
    if (query.trim().length < 2) {
      setSuggestions([]);
      return;
    }

    // Laufende Anfragen abbrechen, damit eine langsame Antwort keine neuere überschreibt.
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      const params = new URLSearchParams({
        apikey: API_KEY,
        q: query.trim(),
        types: 'city',
        limit: '5',
        lang: 'de',
        sessiontoken: session.current,
      });

      try {
        const response = await fetch(`${BASE}/autocomplete?${params}`, {
          signal: controller.signal,
        });
        const data = await response.json();
        setSuggestions(data.suggestions ?? []);
      } catch (error) {
        if (error.name !== 'AbortError') throw error;
      }
    }, 150);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  async function select(suggestion) {
    const params = new URLSearchParams({
      apikey: API_KEY,
      id: suggestion.id,
      type: suggestion.type,
      lang: 'de',
      sessiontoken: session.current,
    });

    const response = await fetch(`${BASE}/details?${params}`);
    const { place } = await response.json();

    setAddress(place);
    setSuggestions([]);
    setQuery(place.name);
    session.current = crypto.randomUUID(); // nächste Adresse, nächste Sitzung
  }

  return { query, setQuery, suggestions, address, select };
}
function AddressField() {
  const { query, setQuery, suggestions, address, select } = useAddressAutocomplete();

  return (
    <div>
      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="Geben Sie Ihre Stadt ein..."
        autoComplete="off"
      />

      {suggestions.length > 0 && (
        <ul>
          {suggestions.map((suggestion) => (
            <li key={suggestion.id} onClick={() => select(suggestion)}>
              {suggestion.description}
            </li>
          ))}
        </ul>
      )}

      {address && (
        <>
          <input readOnly value={address.components.country?.name ?? ''} />
          <input readOnly value={address.components.state?.name ?? ''} />
          <input placeholder={address.postal.example ?? 'Postleitzahl'} />
        </>
      )}
    </div>
  );
}

Abschließende Validierung auf dem Server

Die Validierung im Browser dient dem Nutzer. Bevor Sie etwas speichern oder versenden, bestätigen Sie die vollständige Kombination auf dem Server:

const params = new URLSearchParams({
  apikey: process.env.COUNTRY_DATA_API_KEY,
  country: form.country,
  state: form.state,
  city: form.city,
  zipcode: form.zip,
  lang: 'de',
});

const response = await fetch(
  `https://api.countrydataapi.com/v1/places/validate?${params}`
);
const { result } = await response.json();

if (!result.valid && result.has_corrections) {
  // Bieten Sie die Korrektur an, statt das Formular rundweg abzulehnen.
  return { needsConfirmation: result.corrections };
}

// Speichern Sie die kanonische Form, nicht das, was der Nutzer getippt hat.
await orders.save({
  country: result.normalized.country?.name,
  state: result.normalized.state?.name,
  city: result.normalized.city?.name,
  zipcode: result.normalized.zipcode,
});

result.normalized statt der Roheingabe zu speichern ist das, was Ihre Adressdaten später auswertbar macht: kein Cataluna neben Cataluña, kein MADRID neben Madrid.

Weniger abfragen

Wenn Ihr Formular nur Postleitzahl und Stadt enthält, brauchen Sie das Provinzfeld gar nicht — die Postleitzahl löst es auf:

const { result } = await api.places.validate({ country: 'ES', zipcode: '28001' });
result.normalized.state?.name; // "Autonome Gemeinschaft Madrid"

Ein Feld weniger verbessert die Conversion eines Checkouts messbar.

Was das kostet

Aktion Token
Ein vollständiges Adressfeld (beliebig viele Tastendrücke + details) 1
Serverseitige Validierung des abgeschickten Formulars 1
Postleitzahlformate aller Länder (zwischenspeichern) 1, einmalig

Eine ausgefüllte Checkout-Adresse kostet damit 2 Token, und es spielt keine Rolle, wie schnell der Nutzer tippt.

Was das nicht leistet

Der Datenbestand ist administrativ: Länder, Bundesländer, Städte und Postleitzahlen. Es gibt keine Daten auf Straßenebene, keine Koordinaten unterhalb der Landesebene und keine Points of Interest — keine Geschäfte, Öffnungszeiten, Bewertungen oder Fotos. Wenn Sie eine vollständige Straßenanschrift geokodieren oder ein Restaurant suchen müssen, ist dies nicht das richtige Werkzeug.

Siehe auch