Implementing City Autocomplete Search

javascript
// Search cities in Germany
const response = await fetch(
	"https://worlddataapi.com/v1/cities?country=DE&per_page=50",
	{ headers: { "X-API-Key": "YOUR_API_KEY" } },
);
const data = await response.json();

console.log(data.data[0]);
// {
//   "id": 2950159,
//   "name": "Berlin",
//   "country": "DE",
//   "region": "DE-BE",
//   "timezone": "Europe/Berlin",
//   "latitude": 52.52437,
//   "longitude": 13.41053,
//   "population": 3426354,
//   "is_capital": true
// }
python
import requests

# Search cities in Germany
response = requests.get(
    "https://worlddataapi.com/v1/cities?country=DE&per_page=50",
    headers={"X-API-Key": "YOUR_API_KEY"}
)
data = response.json()

print(data["data"][0])
# {
#   "id": 2950159,
#   "name": "Berlin",
#   "country": "DE",
#   "region": "DE-BE",
#   "timezone": "Europe/Berlin",
#   "latitude": 52.52437,
#   "longitude": 13.41053,
#   "population": 3426354,
#   "is_capital": true
# }
bash
# Search cities in Germany
curl -X GET "https://worlddataapi.com/v1/cities?country=DE&per_page=50" \
  -H "X-API-Key: YOUR_API_KEY"

City autocomplete seems straightforward until you realize there are 35+ Springfields in the United States alone. This guide covers building search that handles ambiguity, presents results intelligently, and stays fast.

The Challenge#

City name search is deceptively complex:

  • Ambiguous names: "Springfield" exists in 35 US states. "San Jose" appears in 14 countries. "Alexandria" spans continents.

  • Diacritics and variants: "Munich" vs "Munchen" vs "Muenchen". Users type what they know.

  • Population differences: Springfield, Illinois (115K) vs Springfield, Missouri (170K) vs Springfield, Massachusetts (155K). Which is "the" Springfield?

  • Missing context: Users often type city names without country or state context.

A naive text search returns confusion instead of results. The API handles this by returning null for ambiguous queries, forcing you to provide context.

Prerequisites#

Before you start, you need:

  • A World Data API key (free tier works for development)

  • Basic JavaScript/React knowledge (examples use React 18+)

  • Node.js 16+ for the code examples

API Tier Considerations:

  • Free tier: 60 requests/day - suitable for development and testing

  • Paid tiers: 15,000+ requests/month with 1,000/hour rate limit

  • City data is static, so aggressive caching reduces API calls significantly

Important: The World Data API covers approximately 12,000 cities focused on significant population centers. It does not include every small town or village. For comprehensive small-town coverage, consider supplementing with additional data sources.

The Ambiguity Problem#

City names aren't unique. "Springfield" exists in 35 US states. "San Jose" appears in 14 countries. A simple text match returns confusion instead of results.

javascript
// This returns null — too many matches
const response = await fetch("https://worlddataapi.com/v1/cities/springfield", {
	headers: { "X-API-Key": API_KEY },
});
const city = await response.json();
console.log(city); // null

The API returns null for ambiguous names by design. There's no correct answer without context.

The solution: require location context. At minimum, narrow by country.

API Limitation: Population data in the API may not always be current. GeoNames updates population figures periodically, but some entries may be outdated. Use population primarily for relative ranking, not as authoritative demographic data.

javascript
async function searchCitiesInCountry(country, query) {
	// Fetch all cities for the country
	const cities = await getCitiesForCountry(country);

	// Filter client-side
	const matches = cities.filter((city) =>
		city.name.toLowerCase().includes(query.toLowerCase()),
	);

	// Sort by population (most relevant first)
	return matches.sort((a, b) => b.population - a.population);
}

async function getCitiesForCountry(country) {
	const response = await fetch(
		`https://worlddataapi.com/v1/cities?country=${country}&per_page=100`,
		{ headers: { "X-API-Key": API_KEY } },
	);
	const data = await response.json();

	// Handle pagination if needed
	let cities = data.data;
	let currentPage = data.pagination.page;
	const totalPages = data.pagination.total_pages;

	while (currentPage < totalPages) {
		currentPage++;
		const nextResponse = await fetch(
			`https://worlddataapi.com/v1/cities?country=${country}&page=${currentPage}&per_page=100`,
			{ headers: { "X-API-Key": API_KEY } },
		);
		const nextData = await nextResponse.json();
		cities = cities.concat(nextData.data);
	}

	return cities;
}

Building the Autocomplete Component#

jsx
import { useState, useEffect, useMemo, useRef } from "react";

function CityAutocomplete({
	country,
	value,
	onChange,
	placeholder = "Search cities...",
}) {
	const [cities, setCities] = useState([]);
	const [loading, setLoading] = useState(true);
	const [query, setQuery] = useState("");
	const [isOpen, setIsOpen] = useState(false);
	const [focusIndex, setFocusIndex] = useState(-1);
	const inputRef = useRef(null);
	const listRef = useRef(null);

	// Load cities for country
	useEffect(() => {
		if (!country) return;

		setLoading(true);
		getCitiesForCountry(country)
			.then(setCities)
			.finally(() => setLoading(false));
	}, [country]);

	// Filter cities based on query
	const filtered = useMemo(() => {
		if (!query.trim()) return cities.slice(0, 10); // Show top 10 by population

		const q = query.toLowerCase();
		return cities
			.filter((city) => city.name.toLowerCase().includes(q))
			.slice(0, 20);
	}, [cities, query]);

	// Find selected city
	const selected = cities.find((c) => c.id === value);

	const handleSelect = (city) => {
		onChange(city.id);
		setQuery("");
		setIsOpen(false);
		setFocusIndex(-1);
	};

	const handleKeyDown = (e) => {
		switch (e.key) {
			case "ArrowDown":
				e.preventDefault();
				setFocusIndex((i) => Math.min(i + 1, filtered.length - 1));
				break;
			case "ArrowUp":
				e.preventDefault();
				setFocusIndex((i) => Math.max(i - 1, 0));
				break;
			case "Enter":
				e.preventDefault();
				if (focusIndex >= 0 && filtered[focusIndex]) {
					handleSelect(filtered[focusIndex]);
				}
				break;
			case "Escape":
				setIsOpen(false);
				setFocusIndex(-1);
				break;
		}
	};

	// Scroll focused item into view
	useEffect(() => {
		if (focusIndex >= 0 && listRef.current) {
			const item = listRef.current.children[focusIndex];
			item?.scrollIntoView({ block: "nearest" });
		}
	}, [focusIndex]);

	if (loading) {
		return <input type="text" disabled placeholder="Loading cities..." />;
	}

	return (
		<div className="city-autocomplete">
			<input
				ref={inputRef}
				type="text"
				value={isOpen ? query : selected?.name || ""}
				onChange={(e) => {
					setQuery(e.target.value);
					setIsOpen(true);
					setFocusIndex(-1);
				}}
				onFocus={() => setIsOpen(true)}
				onBlur={() => setTimeout(() => setIsOpen(false), 150)}
				onKeyDown={handleKeyDown}
				placeholder={placeholder}
				aria-expanded={isOpen}
				aria-haspopup="listbox"
				aria-autocomplete="list"
				role="combobox"
			/>

			{isOpen && filtered.length > 0 && (
				<ul ref={listRef} className="city-list" role="listbox">
					{filtered.map((city, i) => (
						<li
							key={city.id}
							role="option"
							aria-selected={city.id === value}
							className={`city-option ${i === focusIndex ? "focused" : ""}`}
							onClick={() => handleSelect(city)}
						>
							<span className="city-name">{city.name}</span>
							<span className="city-region">{city.region}</span>
							{city.population > 0 && (
								<span className="city-population">
									{formatPopulation(city.population)}
								</span>
							)}
						</li>
					))}
				</ul>
			)}

			{isOpen && query && filtered.length === 0 && (
				<div className="no-results">No cities found</div>
			)}
		</div>
	);
}

function formatPopulation(pop) {
	if (pop >= 1000000) return `${(pop / 1000000).toFixed(1)}M`;
	if (pop >= 1000) return `${(pop / 1000).toFixed(0)}K`;
	return pop.toString();
}

Handling Ambiguous Input#

When users might enter cities from any country, provide disambiguation:

jsx
function GlobalCitySearch({ value, onChange }) {
	const [countries, setCountries] = useState([]);
	const [selectedCountry, setSelectedCountry] = useState("");
	const [query, setQuery] = useState("");
	const [suggestions, setSuggestions] = useState([]);

	// First, select a country
	// Then search within that country

	return (
		<div className="global-city-search">
			<CountrySelector
				value={selectedCountry}
				onChange={(country) => {
					setSelectedCountry(country);
					onChange(null); // Reset city selection
				}}
				placeholder="Select country first..."
			/>

			{selectedCountry && (
				<CityAutocomplete
					country={selectedCountry}
					value={value}
					onChange={onChange}
				/>
			)}
		</div>
	);
}

Progressive Narrowing#

For better UX, let users type and progressively narrow:

jsx
function SmartCitySearch({ onSelect }) {
	const [query, setQuery] = useState("");
	const [step, setStep] = useState("country"); // 'country' | 'region' | 'city'
	const [selectedCountry, setSelectedCountry] = useState(null);
	const [selectedRegion, setSelectedRegion] = useState(null);

	// Start with country autocomplete
	// When country is selected, switch to region/city
	// Show context at each step

	return (
		<div className="smart-search">
			<div className="breadcrumb">
				{selectedCountry && (
					<button
						onClick={() => {
							setSelectedCountry(null);
							setSelectedRegion(null);
							setStep("country");
						}}
					>
						{selectedCountry.name} ×
					</button>
				)}
				{selectedRegion && (
					<button
						onClick={() => {
							setSelectedRegion(null);
							setStep("region");
						}}
					>
						{selectedRegion.name} ×
					</button>
				)}
			</div>

			{step === "country" && (
				<CountryAutocomplete
					onSelect={(country) => {
						setSelectedCountry(country);
						setStep("city");
					}}
				/>
			)}

			{step === "city" && selectedCountry && (
				<CityAutocomplete
					country={selectedCountry.code}
					onChange={(cityId) => {
						// User selected a city
						onSelect({ country: selectedCountry.code, cityId });
					}}
				/>
			)}
		</div>
	);
}

For large countries, narrow by region first:

javascript
async function getCitiesForRegion(region) {
	// Region code is ISO 3166-2, e.g., "US-CA"
	const response = await fetch(
		`https://worlddataapi.com/v1/cities?region=${region}&per_page=100`,
		{ headers: { "X-API-Key": API_KEY } },
	);
	return (await response.json()).data;
}

// Get California cities
const californiaCities = await getCitiesForRegion("US-CA");
jsx
function RegionalCitySearch({ country, onSelect }) {
	const [regions, setRegions] = useState([]);
	const [selectedRegion, setSelectedRegion] = useState("");
	const [cities, setCities] = useState([]);

	useEffect(() => {
		// Load regions for country
		fetch(`https://worlddataapi.com/v1/regions?country=${country}`, {
			headers: { "X-API-Key": API_KEY },
		})
			.then((r) => r.json())
			.then((data) => setRegions(data.data));
	}, [country]);

	useEffect(() => {
		if (!selectedRegion) {
			setCities([]);
			return;
		}

		getCitiesForRegion(selectedRegion).then(setCities);
	}, [selectedRegion]);

	return (
		<div className="regional-search">
			<select
				value={selectedRegion}
				onChange={(e) => setSelectedRegion(e.target.value)}
			>
				<option value="">Select state/region...</option>
				{regions.map((region) => (
					<option key={region.code} value={region.code}>
						{region.name}
					</option>
				))}
			</select>

			{selectedRegion && (
				<CityAutocomplete cities={cities} onSelect={onSelect} />
			)}
		</div>
	);
}

Fuzzy Matching#

Handle typos and partial matches client-side:

javascript
function fuzzyMatch(query, text) {
	const q = query.toLowerCase();
	const t = text.toLowerCase();

	// Exact prefix match (highest priority)
	if (t.startsWith(q)) return { match: true, score: 3 };

	// Contains match
	if (t.includes(q)) return { match: true, score: 2 };

	// Fuzzy match (allow one character difference)
	if (levenshteinDistance(q, t.substring(0, q.length)) <= 1) {
		return { match: true, score: 1 };
	}

	return { match: false, score: 0 };
}

function levenshteinDistance(a, b) {
	const matrix = [];

	for (let i = 0; i <= b.length; i++) {
		matrix[i] = [i];
	}
	for (let j = 0; j <= a.length; j++) {
		matrix[0][j] = j;
	}

	for (let i = 1; i <= b.length; i++) {
		for (let j = 1; j <= a.length; j++) {
			if (b.charAt(i - 1) === a.charAt(j - 1)) {
				matrix[i][j] = matrix[i - 1][j - 1];
			} else {
				matrix[i][j] = Math.min(
					matrix[i - 1][j - 1] + 1,
					matrix[i][j - 1] + 1,
					matrix[i - 1][j] + 1,
				);
			}
		}
	}

	return matrix[b.length][a.length];
}

function searchCities(cities, query) {
	if (!query.trim()) return cities.slice(0, 10);

	return cities
		.map((city) => ({
			city,
			...fuzzyMatch(query, city.name),
		}))
		.filter((r) => r.match)
		.sort((a, b) => {
			// Sort by match quality, then population
			if (b.score !== a.score) return b.score - a.score;
			return b.city.population - a.city.population;
		})
		.map((r) => r.city)
		.slice(0, 20);
}

Displaying Results#

Show enough context to disambiguate:

jsx
function CityResult({ city, highlight }) {
	return (
		<div className="city-result">
			<div className="primary">
				<span className="name">
					{highlightMatch(city.name, highlight)}
				</span>
				{city.is_capital && <span className="badge">Capital</span>}
			</div>
			<div className="secondary">
				<span className="region">{city.region}</span>
				<span className="separator">·</span>
				<span className="country">{city.country}</span>
				{city.population > 0 && (
					<>
						<span className="separator">·</span>
						<span className="population">
							Pop. {formatPopulation(city.population)}
						</span>
					</>
				)}
			</div>
		</div>
	);
}

function highlightMatch(text, query) {
	if (!query) return text;

	const index = text.toLowerCase().indexOf(query.toLowerCase());
	if (index === -1) return text;

	return (
		<>
			{text.substring(0, index)}
			<mark>{text.substring(index, index + query.length)}</mark>
			{text.substring(index + query.length)}
		</>
	);
}

Caching Strategy#

City data is relatively static. Cache aggressively.

Rate Limit Note: The API enforces rate limits of 60 requests/day on the free tier and 1,000 requests/hour on paid tiers. Caching is essential for production applications to stay within limits and provide a responsive user experience.

javascript
class CityCache {
	constructor() {
		this.cache = new Map();
		this.TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
	}

	async getCities(country) {
		const key = `cities:${country}`;

		// Check memory cache
		if (this.cache.has(key)) {
			const { data, timestamp } = this.cache.get(key);
			if (Date.now() - timestamp < this.TTL) {
				return data;
			}
		}

		// Check localStorage
		const stored = localStorage.getItem(key);
		if (stored) {
			const { data, timestamp } = JSON.parse(stored);
			if (Date.now() - timestamp < this.TTL) {
				this.cache.set(key, { data, timestamp });
				return data;
			}
		}

		// Fetch fresh
		const cities = await getCitiesForCountry(country);
		const entry = { data: cities, timestamp: Date.now() };

		this.cache.set(key, entry);
		localStorage.setItem(key, JSON.stringify(entry));

		return cities;
	}

	preload(countries) {
		// Preload common countries
		countries.forEach((country) => this.getCities(country));
	}
}

const cityCache = new CityCache();

// Preload popular countries
cityCache.preload(["US", "GB", "DE", "FR", "JP", "AU", "CA"]);

Debouncing Input#

Don't filter on every keystroke:

javascript
function useDebounce(value, delay) {
	const [debouncedValue, setDebouncedValue] = useState(value);

	useEffect(() => {
		const timer = setTimeout(() => {
			setDebouncedValue(value);
		}, delay);

		return () => clearTimeout(timer);
	}, [value, delay]);

	return debouncedValue;
}

function CityAutocomplete({ country, onChange }) {
	const [query, setQuery] = useState("");
	const debouncedQuery = useDebounce(query, 150);

	const filtered = useMemo(() => {
		return searchCities(cities, debouncedQuery);
	}, [cities, debouncedQuery]);

	// ...
}

Accessibility#

Ensure the autocomplete works with screen readers:

jsx
function AccessibleCityAutocomplete({ country, value, onChange }) {
	const [query, setQuery] = useState("");
	const [results, setResults] = useState([]);
	const [activeIndex, setActiveIndex] = useState(-1);
	const inputId = useId();
	const listId = useId();

	return (
		<div className="city-autocomplete">
			<label htmlFor={inputId} className="sr-only">
				Search for a city
			</label>

			<input
				id={inputId}
				type="text"
				value={query}
				onChange={(e) => setQuery(e.target.value)}
				role="combobox"
				aria-expanded={results.length > 0}
				aria-controls={listId}
				aria-activedescendant={
					activeIndex >= 0 ? `city-option-${activeIndex}` : undefined
				}
				aria-autocomplete="list"
			/>

			<div role="status" aria-live="polite" className="sr-only">
				{results.length > 0
					? `${results.length} cities found`
					: query
						? "No cities found"
						: ""}
			</div>

			{results.length > 0 && (
				<ul id={listId} role="listbox" aria-label="City suggestions">
					{results.map((city, i) => (
						<li
							key={city.id}
							id={`city-option-${i}`}
							role="option"
							aria-selected={i === activeIndex}
							onClick={() => onChange(city.id)}
						>
							{city.name}, {city.region}
							<span className="sr-only">
								, population {city.population.toLocaleString()}
							</span>
						</li>
					))}
				</ul>
			)}
		</div>
	);
}

Styling#

css
.city-autocomplete {
	position: relative;
	width: 100%;
	max-width: 400px;
}

.city-autocomplete input {
	width: 100%;
	padding: 10px 12px;
	font-size: 16px;
	border: 1px solid #ccc;
	border-radius: 4px;
}

.city-autocomplete input:focus {
	outline: none;
	border-color: #007bff;
	box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.city-list {
	position: absolute;
	top: 100%;
	left: 0;
	right: 0;
	margin: 4px 0 0;
	padding: 0;
	list-style: none;
	background: white;
	border: 1px solid #ccc;
	border-radius: 4px;
	box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
	max-height: 300px;
	overflow-y: auto;
	z-index: 100;
}

.city-option {
	display: flex;
	align-items: center;
	gap: 8px;
	padding: 10px 12px;
	cursor: pointer;
}

.city-option:hover,
.city-option.focused {
	background: #f5f5f5;
}

.city-option[aria-selected="true"] {
	background: #e3f2fd;
}

.city-name {
	font-weight: 500;
}

.city-region {
	color: #666;
	font-size: 0.9em;
}

.city-population {
	margin-left: auto;
	color: #999;
	font-size: 0.85em;
}

.no-results {
	padding: 12px;
	text-align: center;
	color: #666;
	background: white;
	border: 1px solid #ccc;
	border-radius: 4px;
	margin-top: 4px;
}

.sr-only {
	position: absolute;
	width: 1px;
	height: 1px;
	padding: 0;
	margin: -1px;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	border: 0;
}

mark {
	background: #fff3cd;
	padding: 0;
}

React Hook#

Encapsulate everything in a reusable hook:

javascript
function useCitySearch(country) {
	const [cities, setCities] = useState([]);
	const [loading, setLoading] = useState(false);
	const [error, setError] = useState(null);

	useEffect(() => {
		if (!country) {
			setCities([]);
			return;
		}

		setLoading(true);
		setError(null);

		cityCache
			.getCities(country)
			.then(setCities)
			.catch(setError)
			.finally(() => setLoading(false));
	}, [country]);

	const search = useCallback(
		(query) => {
			return searchCities(cities, query);
		},
		[cities],
	);

	const getById = useCallback(
		(id) => {
			return cities.find((c) => c.id === id);
		},
		[cities],
	);

	return {
		cities,
		loading,
		error,
		search,
		getById,
	};
}

Complete Example#

jsx
function LocationPicker({ onLocationSelect }) {
	const [country, setCountry] = useState("");
	const [cityId, setCityId] = useState(null);
	const { cities, loading, getById } = useCitySearch(country);

	const handleCitySelect = (id) => {
		setCityId(id);
		const city = getById(id);
		if (city) {
			onLocationSelect({
				country,
				city: city.name,
				cityId: id,
				region: city.region,
				timezone: city.timezone,
				coordinates: {
					lat: city.latitude,
					lng: city.longitude,
				},
			});
		}
	};

	return (
		<div className="location-picker">
			<CountrySelector
				value={country}
				onChange={(code) => {
					setCountry(code);
					setCityId(null);
				}}
			/>

			{country && (
				<CityAutocomplete
					country={country}
					value={cityId}
					onChange={handleCitySelect}
					loading={loading}
				/>
			)}

			{cityId && getById(cityId) && (
				<div className="selected-location">
					Selected: {getById(cityId).name}, {country}
				</div>
			)}
		</div>
	);
}

Common Pitfalls#

Searching without country context#

The most common mistake is allowing global city search without requiring country selection first. This leads to ambiguous results and frustrated users.

javascript
// Bad: Global search returns too many matches
const results = await searchCities("Springfield");
// Returns 35+ results with no clear winner

// Good: Scoped search returns useful results
const results = await searchCities("Springfield", {
	country: "US",
	region: "US-IL",
});
// Returns Springfield, Illinois

Not handling API pagination#

For countries with many cities, results span multiple pages. The API returns a maximum of 100 items per page. Failing to paginate means missing cities.

Ignoring population data#

When multiple cities match, population is your best disambiguation signal. A search for "Paris" in France should prioritize the capital (2.1M) over Paris, Texas (25K).

Over-fetching on every keystroke#

Avoid making API calls on every keystroke. Use debouncing (150-300ms delay) and cache city data aggressively since it rarely changes.

Forgetting GeoNames ID instability#

Warning: GeoNames IDs may change over time as the GeoNames database is maintained. If you store city references long-term, consider storing coordinates or city name + region as a fallback.

Assuming comprehensive coverage#

The API covers approximately 12,000 cities focused on population centers. If your application needs comprehensive small-town coverage, plan for users who cannot find their location and provide a manual entry fallback.

Not handling null responses#

The API returns null for ambiguous city name lookups by design. Always handle this case in your UI:

javascript
const city = await fetchCity("Springfield");
if (city === null) {
	// Show disambiguation UI or ask for more context
	showCountrySelector();
}

Summary#

Building effective city autocomplete requires handling ambiguity, not fighting it. Key takeaways:

  1. Require country context before city search to eliminate ambiguity

  2. Cache aggressively - city data rarely changes; a 7-day TTL is reasonable

  3. Sort by population when multiple cities match to surface the most relevant results

  4. Handle pagination to avoid missing cities in countries with large datasets

  5. Implement fuzzy matching client-side to handle typos and diacritics

  6. Build accessible components with proper ARIA attributes and keyboard navigation

The combination of scoped search, population-based ranking, and client-side fuzzy matching creates a search experience that helps users find the right city quickly.

Ready to build? Get your free API key and start with 60 requests/day at no cost.