ReactCitiesTutorial

Building a City Autocomplete with 150K Cities — Tutorial

ApogeoAPI6 min read

City autocomplete sounds simple until you realize you need to search across 150,000 cities from 250 countries — with fast response times and no UI jank. Here's how to do it right.

The Challenge

You can't load 150K cities into the browser — that's tens of megabytes. You need server-side search that returns relevant results as the user types. ApogeoAPI's global search endpoint handles this.

The API Approach

Use the global search endpoint for cross-country city search:

GET /v1/search?q=lon&limit=5
// Returns cities, states, and countries matching "lon"
// Results: London GB, Long Beach US, Longueuil CA, ...

Step 1: Build the Search Hook with Debounce

// hooks/useCitySearch.ts
import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, ms: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(timer);
  }, [value, ms]);
  return debounced;
}

interface CityResult {
  name: string;
  stateCode: string;
  stateName: string;
  countryCode: string;
  countryName: string;
  latitude: number;
  longitude: number;
}

export function useCitySearch(query: string) {
  const [results, setResults] = useState<CityResult[]>([]);
  const [loading, setLoading] = useState(false);
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery.length < 2) { setResults([]); return; }

    setLoading(true);
    fetch(`https://api.apogeoapi.com/v1/search?q=${encodeURIComponent(debouncedQuery)}&limit=8`, {
      headers: { 'X-API-Key': process.env.NEXT_PUBLIC_APOGEO_KEY! },
    })
      .then(r => r.json())
      .then(data => setResults(data.cities ?? []))
      .finally(() => setLoading(false));
  }, [debouncedQuery]);

  return { results, loading };
}

Step 2: The Autocomplete Component

'use client';
import { useState } from 'react';
import { useCitySearch } from '@/hooks/useCitySearch';

interface Props {
  onSelect: (city: { name: string; country: string; lat: number; lon: number }) => void;
}

export function CityAutocomplete({ onSelect }: Props) {
  const [query, setQuery] = useState('');
  const { results, loading } = useCitySearch(query);

  return (
    <div className="relative">
      <input
        type="text"
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search city..."
        className="w-full border rounded px-3 py-2"
      />

      {loading && (
        <div className="absolute right-3 top-3 text-gray-400 text-sm">...</div>
      )}

      {results.length > 0 && (
        <ul className="absolute z-10 w-full bg-white border rounded shadow-lg max-h-64 overflow-y-auto">
          {results.map(city => (
            <li
              key={`${city.name}-${city.countryCode}`}
              onClick={() => {
                onSelect({ name: city.name, country: city.countryCode,
                  lat: city.latitude, lon: city.longitude });
                setQuery(`${city.name}, ${city.stateName}, ${city.countryCode}`);
                // close dropdown implicitly by clearing results
              }}
              className="px-4 py-2.5 hover:bg-gray-50 cursor-pointer"
            >
              <span className="font-medium">{city.name}</span>
              <span className="text-gray-500 text-sm ml-1">
                {city.stateName}, {city.countryCode}
              </span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Performance Notes

  • Debounce 300ms: Prevents a request on every keystroke.
  • Minimum 2 characters: Avoids overly broad results on single letters.
  • Limit to 8 results: Users don't scroll through more than that in a dropdown.
  • Cache recent searches: Store the last 10 queries in a useRef Map to avoid re-fetching the same query.

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key