Skip to content

Guide

How to add address autocomplete to a React app

A complete React component that suggests addresses as the user types, using the geolith autocomplete API with a browser-safe publishable key, debouncing, request cancelling and keyboard support.

By · 12 min · updated

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:

FieldExample
properties.labelTimes Square, Manhattan Community Board 5, Manhattan, New York, United States
properties.gidgeolith:venue:4611686019129928222 (store it to look the place up later)
properties.layeraddress, venue, street, locality, ...
properties.postalcode10176
geometry.coordinates[-73.985955, 40.757195] (longitude, latitude)

Tips

  • Bias to the user's city. Add focus.point.lat and focus.point.lon to rank nearby addresses first.
  • Only addresses. Add layers=address,street to hide shops and landmarks.
  • Watch the quota. Every response has X-Quota-Remaining. Over quota, the API answers HTTP 429 quota_exceeded until 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.

Questions

Quick answers

Is it safe to put the geolith key in React code?
Yes, if it is a publishable key (gc_pk_...) with allowed origins. It only answers requests from those origins; other sites get HTTP 403 origin_not_allowed. Never put a secret key (gc_sk_...) in front-end code.
How many requests does autocomplete use?
One per debounced pause in typing. With a 200 ms debounce and a 3-character minimum, a typical address costs 2 to 4 requests. The Free plan includes 100,000 a month.
Does this work with Next.js?
Yes. Mark the component with 'use client' and read the key from process.env.NEXT_PUBLIC_GEOLITH_KEY instead of import.meta.env.

Geocode your first address in a minute.

100,000 requests a month free. Commercial use with attribution. No card.