# 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.

---
title: How to add address autocomplete to a React app
description: 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.
date: '2026-10-22'
tags: [react, autocomplete, javascript]
minutes: 12
steps:
  - name: Create a publishable key
    text: Sign up at console.geolith.app, create a publishable key (gc_pk_...) and add your site's origins, for example http://localhost:5173 and https://shop.example.com.
  - name: Store the key in an environment variable
    text: Put VITE_GEOLITH_KEY=gc_pk_... in .env.local so it is not hard-coded.
  - name: Add the AddressAutocomplete component
    text: Copy the component. It waits 200 ms after typing, cancels the previous request, and calls /v1/autocomplete, optionally limited with boundary.country.
  - name: Use the selected address
    text: The onSelect callback receives the label, coordinates and address parts. Fill your form fields from it.
  - name: Show attribution
    text: Show "© OpenStreetMap contributors (ODbL), Overture Maps Foundation" under the suggestions.
faqs:
  - q: Is it safe to put the geolith key in React code?
    a: 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.
  - q: How many requests does autocomplete use?
    a: 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.
  - q: Does this work with Next.js?
    a: Yes. Mark the component with 'use client' and read the key from process.env.NEXT_PUBLIC_GEOLITH_KEY instead of import.meta.env.
---

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](https://console.geolith.app/signup) 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):

```bash
VITE_GEOLITH_KEY=YOUR_API_KEY
```

## 2. The component

Save this as `AddressAutocomplete.jsx`:

```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

```jsx
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.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](/guides/address-autocomplete-vue-nuxt), or [store the coordinates and build a store locator](/guides/store-locator-maplibre).


Get a free API key: https://console.geolith.app/signup
