Documentación de la API - Endpoints y Ejemplos

Autocompletado de Direcciones

De un input vacío a una dirección validada y guardada

Esta guía construye el paso de dirección de un checkout: el usuario teclea, elige una ciudad, y los campos de país, provincia y código postal se rellenan solos y se validan. Unas 60 líneas de JavaScript.

El trabajo lo hacen tres endpoints:

  1. /v1/places/autocomplete — sugerencias mientras se teclea
  2. /v1/places/details — la ficha completa de lo elegido
  3. /v1/places/validate — comprobación final antes de guardar

Primero, el token de sesión

Un autocompletado envía una petición por tecla. Para que el coste sea predecible, genera un sessiontoken cuando el campo recibe el foco y reutilízalo en todas las peticiones de ese campo, incluida la llamada final a details. Todo el campo cuesta 1 token, escriba lo que escriba el usuario.

let session = crypto.randomUUID();

input.addEventListener('focus', () => {
  session = crypto.randomUUID(); // una sesión por cada dirección que introduzca el usuario
});

Las sesiones duran 3 minutos. Si omites el token, cada petición cuesta 1 token.

JavaScript puro

<input id="city" placeholder="Empieza a escribir tu ciudad..." autocomplete="off" />
<ul id="suggestions"></ul>

<input id="country" readonly />
<input id="state" readonly />
<input id="zip" placeholder="Código postal" />
<p id="zip-error"></p>
const API_KEY = 'tu-clave-api';
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: una petición por pausa, no por tecla.
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: 'es',
    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, Comunidad de Madrid, España"
    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: 'es',
    sessiontoken: session, // cierra la sesión: esta llamada es gratuita
  });

  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 ?? '';

  // Las reglas de código postal del país, para validar el siguiente campo en el navegador.
  postal = place.postal;
  document.getElementById('zip').placeholder = postal.example ?? 'Código postal';
}

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 ? '' : `Formato esperado: ${postal.example ?? postal.format}`;
});

Esa es toda la interacción. El campo description ya viene formateado para un desplegable, así que no hay que componer cadenas.

React

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

const API_KEY = 'tu-clave-api';
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;
    }

    // Aborta las peticiones en vuelo para que una respuesta lenta no pise a otra más reciente.
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      const params = new URLSearchParams({
        apikey: API_KEY,
        q: query.trim(),
        types: 'city',
        limit: '5',
        lang: 'es',
        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: 'es',
      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(); // siguiente dirección, siguiente sesión
  }

  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="Empieza a escribir tu ciudad..."
        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 ?? 'Código postal'} />
        </>
      )}
    </div>
  );
}

Validación final en el servidor

La validación en el navegador es para comodidad del usuario. Antes de guardar o enviar nada, confirma la combinación completa en el servidor:

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

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

if (!result.valid && result.has_corrections) {
  // Ofrece la corrección en lugar de rechazar el formulario sin más.
  return { needsConfirmation: result.corrections };
}

// Guarda la forma canónica, no lo que escribió el usuario.
await orders.save({
  country: result.normalized.country?.name,
  state: result.normalized.state?.name,
  city: result.normalized.city?.name,
  zipcode: result.normalized.zipcode,
});

Guardar result.normalized en vez de la entrada en bruto es lo que hace que tus datos de direcciones sean consultables después: ni un Cataluna junto a un Cataluña, ni un MADRID junto a un Madrid.

Pedir menos

Si tu formulario solo tiene código postal y ciudad, no necesitas el campo de provincia: el código postal lo resuelve.

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

Un campo menos mejora la conversión de un checkout de forma medible.

Cuánto cuesta esto

Acción Tokens
Un campo de dirección completo (las teclas que sean + details) 1
Validación en servidor del formulario enviado 1
Formatos postales de todos los países (cachéalo) 1, una vez

Así que una dirección de checkout completada cuesta 2 tokens, y da igual lo rápido que teclee el usuario.

Lo que esto no hace

El conjunto de datos es administrativo: países, estados, ciudades y códigos postales. No hay datos a nivel de calle, ni coordenadas por debajo de país, ni puntos de interés — ni negocios, ni horarios, ni valoraciones, ni fotos. Si necesitas geocodificar una dirección postal completa o buscar un restaurante, esta no es la herramienta adecuada.

Relacionado