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:
/v1/places/autocomplete— sugerencias mientras se teclea/v1/places/details— la ficha completa de lo elegido/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
- GET /v1/places/autocomplete
- GET /v1/places/details
- GET /v1/places/validate
- GET /v1/places/postal-format
- Selects en cascada con los endpoints Select — el enfoque más simple con desplegables