How to Add a City Autocomplete Dropdown to Any Web Form (Step-by-Step)
A plain text input for "city" is a UX dead end. Here's how to replace it with a fast, searchable dropdown in under 30 minutes.
Your user stares at a blank text field labeled "City." They type "San" and wait. Nothing happens. They type "San Francisco" and hope the backend can figure it out. Sometimes it can. But sometimes it stores "San Fransisco" (their typo) and your data is polluted.
City autocomplete solves this. The user types a few characters, a dropdown appears with real city names, and they pick one. Clean data in, clean data out. No fuzzy matching, no validation headaches, and no misspelled records in your database.
Here's how to build one from scratch using plain HTML, JavaScript, and a city search API. No frameworks required.
What you'll build
A text input that:
- Starts searching after 3 characters
- Debounces keystrokes (so you're not firing a request per letter)
- Shows a dropdown with city name, region, and country
- Returns structured data (name, coordinates, country code) when the user selects a result
Total code: under 100 lines of JavaScript.
Step 1: The HTML
Start with a wrapper, an input, and an empty container for results.
<div class="city-autocomplete">
<input
type="text"
id="city-input"
placeholder="Start typing a city..."
autocomplete="off"
/>
<div id="city-results" class="results-dropdown"></div>
</div>
The autocomplete="off" attribute prevents the browser's built-in autocomplete from fighting yours. The results container starts empty and the JavaScript will populate it.
Step 2: Connect to a city search API
You need an API that takes a partial city name and returns matches. This example uses the Tova Cities API, which searches 56,000+ cities worldwide and returns results in under 200ms.
The endpoint:
GET https://cities-v1.tovaapis.com/search?q={query}&iso3={country}&n=5
The response includes structured city data: name, region, country, coordinates, and a universal city code (UCC) you can store as a stable identifier.
Here's the fetch wrapper:
const API_BASE = "https://cities-v1.tovaapis.com";
const API_KEY = "your_api_key_here"; // get one free at tovaapis.com
async function searchCities(query, countryCode = "USA") {
const params = new URLSearchParams({
q: query,
iso3: countryCode,
n: "5"
});
const res = await fetch(`${API_BASE}/search?${params}`, {
headers: { "X-API-Key": API_KEY }
});
if (!res.ok) return [];
const data = await res.json();
return data.data || [];
}
The n=5 parameter caps results at 5, which is enough for a dropdown without overwhelming the user. The iso3 parameter is required and scopes the search to a single country. To let users search different countries, pair it with a country selector that updates the argument.
Step 3: Debounce the input
Without debouncing, every keystroke fires a network request. Typing "Boulder" sends 7 requests when you only need 1 or 2. A simple debounce waits until the user pauses typing before searching.
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
300ms is the standard delay for autocomplete. Fast enough to feel responsive; slow enough to batch keystrokes.
Step 4: Render the dropdown
When results come back, build a list of clickable items. Each item shows the city name, region, and country so the user can distinguish "Portland, Oregon" from "Portland, Maine."
const input = document.getElementById("city-input");
const dropdown = document.getElementById("city-results");
function renderResults(cities) {
if (cities.length === 0) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
return;
}
dropdown.innerHTML = cities
.map(city => `
<div class="result-item" data-ucc="${city.ucc}">
<strong>${city.name}</strong>
<span>${city.regionName}, ${city.countryName}</span>
</div>
`)
.join("");
dropdown.style.display = "block";
}
The data-ucc attribute stores the city's unique code on each item, so you can retrieve it on click without searching again.
Step 5: Wire it together
Connect the input to the search function, and handle clicks on results.
const handleSearch = debounce(async (e) => {
const query = e.target.value.trim();
if (query.length < 3) {
dropdown.style.display = "none";
return;
}
const cities = await searchCities(query);
renderResults(cities);
}, 300);
input.addEventListener("input", handleSearch);
dropdown.addEventListener("click", (e) => {
const item = e.target.closest(".result-item");
if (!item) return;
input.value = item.querySelector("strong").textContent;
dropdown.style.display = "none";
// store the structured data for form submission
const ucc = item.dataset.ucc;
console.log("Selected city UCC:", ucc);
});
// close dropdown when clicking outside
document.addEventListener("click", (e) => {
if (!e.target.closest(".city-autocomplete")) {
dropdown.style.display = "none";
}
});
The 3-character minimum matches the API's own requirement and prevents overly broad searches. The outside click handler keeps the UI clean.
Step 6: Basic styling
The dropdown needs enough CSS to float over the page content. Here's a starting point:
.city-autocomplete {
position: relative;
max-width: 400px;
}
#city-input {
width: 100%;
padding: 10px 12px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 6px;
}
.results-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
margin-top: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.result-item {
padding: 10px 12px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.result-item:hover {
background: #f5f5f5;
}
.result-item span {
font-size: 13px;
color: #888;
}
What you get back
When the user selects a city, the API response includes everything you'd want to store:
{
"ucc": "sanf",
"name": "San Francisco",
"country": "USA",
"countryName": "United States",
"region": "california",
"regionName": "California",
"population": 873965,
"latitude": 37.7749,
"longitude": -122.4194,
"aliases": []
}
The ucc (universal city code) is a stable short identifier, such as sanf for San Francisco or nyc for New York City. You can store this in your database instead of a raw string. If you need coordinates for mapping or distance calculations later, they are already in the response.
Going further
This covers the core pattern. A few things to consider for production:
- Country selector. Add a dropdown that sets the
iso3parameter so users can search within any country, not just the US. - Keyboard navigation. Arrow keys to move through results, then hit Enter to select. Adds polish without much code.
- Loading state. Show a spinner or "Searching..." text while the API call is in flight.
- Error handling. If the API returns an error or the network fails, degrade gracefully to a plain text input.
- Form integration. Store the UCC in a hidden input field so it submits with the form alongside the display name.
A full working example (HTML, CSS, and JS in a single file) is available on GitHub.
Fast, flat-priced city data for your app.
56,000+ cities worldwide. Sub-200ms responses. 1,000 free test requests to get started.
Learn more