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