// 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
// }
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
# }
# 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.
// 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.
Scoped City Search#
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.
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#
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:
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:
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>
);
}
Region-Scoped Search#
For large countries, narrow by region first:
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");
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:
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:
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.
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:
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:
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#
.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:
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#
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.
// 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:
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:
Require country context before city search to eliminate ambiguity
Cache aggressively - city data rarely changes; a 7-day TTL is reasonable
Sort by population when multiple cities match to surface the most relevant results
Handle pagination to avoid missing cities in countries with large datasets
Implement fuzzy matching client-side to handle typos and diacritics
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.
Building a Country Selector Dropdown - The companion country picker
Working with ISO Country and Region Codes - Understanding location codes
Caching Strategies for Reference Data APIs - Optimize API usage