This guide builds an address autocomplete for a React app with the geolith API. When the user types three or more characters, the component waits 200 ms, calls GET https://api.geolith.app/v1/autocomplete, and shows up to six suggestions. Arrow keys move through them, Enter picks one, and your onSelect callback gets the label, the coordinates and the address parts. It works in any React 18 or 19 app (Vite, Next.js, Remix).
1. Get a publishable key
Sign up at console.geolith.app and create a publishable key. Publishable keys start with gc_pk_ and only answer requests whose Origin is on the key's allowed list, so they are safe in browser code. Add http://localhost:5173 for development and your production origin.
Put it in .env.local (Vite):
VITE_GEOLITH_KEY=YOUR_API_KEY
2. The component
Save this as AddressAutocomplete.jsx:
import { useEffect, useId, useRef, useState } from 'react';
const ENDPOINT = 'https://api.geolith.app/v1/autocomplete';
const API_KEY = import.meta.env.VITE_GEOLITH_KEY; // gc_pk_..., locked to your origins
export function AddressAutocomplete({ onSelect, country, placeholder = 'Start typing an address' }) {
const [text, setText] = useState('');
const [items, setItems] = useState([]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const skipNext = useRef(false);
const listId = useId();
useEffect(() => {
if (skipNext.current) {
skipNext.current = false;
return;
}
if (text.trim().length < 3) {
setItems([]);
setOpen(false);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
const url = new URL(ENDPOINT);
url.searchParams.set('text', text);
url.searchParams.set('size', '6');
if (country) url.searchParams.set('boundary.country', country); // e.g. 'IN' or 'FR,BE'
url.searchParams.set('api_key', API_KEY);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
console.warn('geolith autocomplete', res.status, await res.text());
return;
}
const data = await res.json();
setItems(data.features);
setActive(-1);
setOpen(data.features.length > 0);
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
}
}, 200);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [text, country]);
function choose(feature) {
const p = feature.properties;
const [lon, lat] = feature.geometry.coordinates; // GeoJSON: [lon, lat]
skipNext.current = true;
setText(p.label);
setOpen(false);
onSelect?.({
label: p.label,
gid: p.gid,
lat,
lon,
street: p.street ?? p.name ?? '',
locality: p.locality ?? '',
region: p.region ?? '',
postalcode: p.postalcode ?? '',
countryCode: p.country_code ?? '',
});
}
function onKeyDown(e) {
if (!open) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && active >= 0) {
e.preventDefault();
choose(items[active]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<div style={{ position: 'relative', maxWidth: 520 }}>
<input
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
autoComplete="off"
value={text}
placeholder={placeholder}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => setTimeout(() => setOpen(false), 150)}
style={{ width: '100%', padding: '10px 12px', fontSize: 16 }}
/>
{open && (
<ul
id={listId}
role="listbox"
style={{ position: 'absolute', zIndex: 10, left: 0, right: 0, margin: 0, padding: 0, listStyle: 'none', background: '#fff', border: '1px solid #ccc' }}
>
{items.map((f, i) => (
<li
key={f.properties.gid}
role="option"
aria-selected={i === active}
onMouseDown={() => choose(f)}
style={{ padding: '8px 12px', cursor: 'pointer', background: i === active ? '#eef7f6' : undefined }}
>
{f.properties.label}
</li>
))}
<li style={{ padding: '4px 12px', fontSize: 11, color: '#666' }}>
© OpenStreetMap contributors (ODbL), Overture Maps Foundation
</li>
</ul>
)}
</div>
);
}
3. Use it in a form
import { useState } from 'react';
import { AddressAutocomplete } from './AddressAutocomplete';
export default function CheckoutAddress() {
const [address, setAddress] = useState(null);
return (
<form>
<label>Delivery address</label>
<AddressAutocomplete onSelect={setAddress} />
{address && (
<dl>
<dt>Street</dt><dd>{address.street}</dd>
<dt>City</dt><dd>{address.locality}</dd>
<dt>Postal code</dt><dd>{address.postalcode}</dd>
<dt>Coordinates</dt><dd>{address.lat}, {address.lon}</dd>
</dl>
)}
</form>
);
}
What the API returns
Each suggestion is a GeoJSON feature. For text=times squ the first one is:
| Field | Example |
|---|---|
properties.label | Times Square, Manhattan Community Board 5, Manhattan, New York, United States |
properties.gid | geolith:venue:4611686019129928222 (store it to look the place up later) |
properties.layer | address, venue, street, locality, ... |
properties.postalcode | 10176 |
geometry.coordinates | [-73.985955, 40.757195] (longitude, latitude) |
Tips
- Bias to the user's city. Add
focus.point.latandfocus.point.lonto rank nearby addresses first. - Only addresses. Add
layers=address,streetto hide shops and landmarks. - Watch the quota. Every response has
X-Quota-Remaining. Over quota, the API answers HTTP 429quota_exceededuntil the month ends; the component above logs it and shows nothing. - Coverage. Worldwide: the hosted API answers from one planet index of OpenStreetMap and Overture places. Pass
country="IN"(or any ISO code) to limit suggestions to the countries you serve.
Next: the same component in Vue and Nuxt, or store the coordinates and build a store locator.