You do not need a framework to build a good country, state and city picker. A single HTML file, about a hundred lines of JavaScript and a small Node.js server are enough, and the server does one important job: it keeps your API key out of the browser and out of your public repository. This guide builds all three pieces with accessible labels, clear loading and error states, and caching at two levels. If your app uses React, the React country selector guide is a better starting point.
Architecture
- Browser: plain HTML and an ES module that calls
/api/locations/...on your own domain - Node.js proxy: an Express route that adds the API key, forwards the request to CountryDataAPI and caches successful answers
- CountryDataAPI: the three select endpoints built for dropdowns
You need Node.js 18 or later (for the built-in fetch) and an API key. Create an account to get one. Paid plans start at €5/month with 200,000 monthly tokens; details are on the pricing page.
The Endpoints
All three endpoints live under https://api.countrydataapi.com/v1/select, take the apikey query parameter and accept an optional lang (en, es, pt, fr, de, it):
/countriesreturns{ id, name, code, phone_code, flag }for every country/states?country=COUNTRY_IDreturns{ id, name }for each state or province (docs)/cities?state=STATE_ID&country=COUNTRY_IDreturns{ id, name }for each city;countryis optional but helps when two states share a name (docs)
Each request consumes 1 token, no matter how long the list is. The general endpoints, such as states by country, charge per item returned and use a fields parameter to decide the cost tier. Use them only when you need more than an ID and a name, and even then request only the basic fields.
A successful response has the shape { "success": true, "data": [...], "count": 17, "tokens_used": 1 }. An invalid key or an empty token balance returns { "error": true, "message": "..." }, and an unknown country or state returns { "success": false, "data": [] }. Both use HTTP 200, so check the body as well as the status code.
Why a Proxy?
The key goes in the query string, so a page that calls the API directly exposes it in the page source, the Network tab and any repository where the file is committed. Anyone who copies it can spend your tokens. The authentication docs put it plainly: treat the key like a password. A proxy fixes this, and it also lets you add caching, rate limiting and an allow-list of parameters.
Step 1: The Express Proxy
npm init -y
npm install express
npm pkg set type=module
// server.js
import express from 'express';
const API_BASE = 'https://api.countrydataapi.com/v1/select';
const API_KEY = process.env.COUNTRY_DATA_API_KEY;
const RESOURCES = new Set(['countries', 'states', 'cities']);
const FORWARDED_PARAMS = ['country', 'state', 'lang'];
const TTL_MS = 24 * 60 * 60 * 1000;
const cache = new Map();
if (!API_KEY) {
console.error('Set COUNTRY_DATA_API_KEY before starting the server.');
process.exit(1);
}
const app = express();
app.use(express.static('public'));
app.get('/api/locations/:resource', async (req, res) => {
const { resource } = req.params;
if (!RESOURCES.has(resource)) {
return res.status(404).json({ error: true, message: 'Unknown resource.' });
}
// Forward only the parameters we expect, never the client's apikey.
const query = new URLSearchParams();
for (const name of FORWARDED_PARAMS) {
const value = req.query[name];
if (typeof value === 'string' && value.length > 0 && value.length <= 100) {
query.set(name, value);
}
}
const cacheKey = `${resource}?${query}`;
const hit = cache.get(cacheKey);
if (hit && hit.expires > Date.now()) {
return res.set('Cache-Control', 'public, max-age=3600').json(hit.body);
}
query.set('apikey', API_KEY);
try {
const upstream = await fetch(`${API_BASE}/${resource}?${query}`, {
signal: AbortSignal.timeout(8000),
});
const body = await upstream.json();
if (!upstream.ok || body.error) {
// Log the real reason (bad key, no tokens) but do not expose it.
console.error('CountryDataAPI error:', upstream.status, body.message);
return res.status(502).json({ error: true, message: 'Location service unavailable.' });
}
if (body.success) {
cache.set(cacheKey, { body, expires: Date.now() + TTL_MS });
res.set('Cache-Control', 'public, max-age=3600');
}
return res.json(body);
} catch (err) {
console.error('CountryDataAPI request failed:', err);
return res.status(502).json({ error: true, message: 'Location service unavailable.' });
}
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`Listening on http://localhost:${port}`));
Start it with the key in an environment variable, and add the files that hold secrets to .gitignore:
# macOS / Linux
COUNTRY_DATA_API_KEY=your_api_key_here node server.js
# .gitignore
node_modules/
.env
If you prefer a .env file, Node.js 20.6+ can load it with node --env-file=.env server.js. The server cache means a thousand visitors choosing Spain cost one token between them, not a thousand. In production, also put a rate limiter (such as express-rate-limit) in front of the route so nobody can use your proxy to drain your balance.
The proxy also gives you one place to change later. If you switch the lang default, add logging, or move to a different cache, the browser code does not change.
Step 2: Accessible Markup
Save this as public/index.html. Each select has a visible <label for>, starts disabled, and points to a shared status message. The status and alert regions are in the page from the start, because screen readers announce changes to an existing live region more reliably than a region that was just added.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shipping address</title>
</head>
<body>
<main>
<h1>Shipping address</h1>
<form id="location-form" novalidate>
<div class="field">
<label for="country">Country</label>
<select id="country" name="country" required disabled aria-describedby="location-status">
<option value="">Loading countries...</option>
</select>
</div>
<div class="field">
<label for="state">State / province</label>
<select id="state" name="state" required disabled aria-describedby="location-status">
<option value="">Select a country first</option>
</select>
</div>
<div class="field">
<label for="city">City</label>
<select id="city" name="city" required disabled aria-describedby="location-status">
<option value="">Select a state first</option>
</select>
</div>
<p id="location-status" role="status" aria-live="polite"></p>
<p id="location-error" role="alert"></p>
<button type="button" id="retry" hidden>Try again</button>
<button type="submit" id="submit" disabled>Continue</button>
</form>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
Step 3: The Cascading Logic
Save this as public/app.js. Options are created with new Option(text, value), which sets the text safely without parsing HTML, so API data can never inject markup into the page.
// public/app.js
const API = '/api/locations';
const LANG = document.documentElement.lang || 'en';
const form = document.querySelector('#location-form');
const countrySelect = document.querySelector('#country');
const stateSelect = document.querySelector('#state');
const citySelect = document.querySelector('#city');
const statusEl = document.querySelector('#location-status');
const errorEl = document.querySelector('#location-error');
const retryBtn = document.querySelector('#retry');
const submitBtn = document.querySelector('#submit');
const cache = new Map();
let retryAction = null;
function fetchOptions(resource, query = {}) {
const url = `${API}/${resource}?${new URLSearchParams({ ...query, lang: LANG })}`;
if (!cache.has(url)) {
const request = fetch(url)
.then(async (res) => {
const body = await res.json().catch(() => ({}));
if (!res.ok || body.error) {
throw new Error(body.message || `Request failed with status ${res.status}.`);
}
if (!body.success) throw new Error('Nothing was found for this selection.');
return body.data ?? [];
})
.catch((err) => {
cache.delete(url); // allow a real retry
throw err;
});
cache.set(url, request);
}
return cache.get(url);
}
function fillSelect(select, items, placeholder) {
const options = [new Option(placeholder, '')];
for (const item of items) options.push(new Option(item.name, item.id));
select.replaceChildren(...options);
}
function resetSelect(select, placeholder) {
select.dataset.seq = String(Number(select.dataset.seq || 0) + 1); // cancel pending loads
delete select.dataset.empty;
select.removeAttribute('aria-busy');
select.disabled = true;
fillSelect(select, [], placeholder);
}
function showError(message, action) {
errorEl.textContent = message;
retryAction = action;
retryBtn.hidden = !action;
}
function clearError() {
errorEl.textContent = '';
retryAction = null;
retryBtn.hidden = true;
}
async function populate(select, { resource, query, noun, placeholder, emptyText }) {
const seq = String(Number(select.dataset.seq || 0) + 1);
select.dataset.seq = seq;
delete select.dataset.empty;
clearError();
select.disabled = true;
select.setAttribute('aria-busy', 'true');
fillSelect(select, [], `Loading ${noun}...`);
statusEl.textContent = `Loading ${noun}...`;
updateSubmit();
try {
const items = await fetchOptions(resource, query);
if (select.dataset.seq !== seq) return; // the user changed the parent meanwhile
fillSelect(select, items, placeholder);
select.disabled = items.length === 0;
if (items.length === 0) select.dataset.empty = 'true';
statusEl.textContent = items.length === 0 ? emptyText : '';
} catch (err) {
if (select.dataset.seq !== seq) return;
fillSelect(select, [], placeholder);
statusEl.textContent = '';
showError(err.message, () => populate(select, { resource, query, noun, placeholder, emptyText }));
} finally {
if (select.dataset.seq === seq) {
select.removeAttribute('aria-busy');
updateSubmit();
}
}
}
// A level is satisfied when it has a value or loaded with no options at all.
function isSatisfied(select) {
return select.value !== '' || select.dataset.empty === 'true';
}
function updateSubmit() {
const busy = form.querySelector('[aria-busy="true"]') !== null;
const stateDone = isSatisfied(stateSelect);
const cityDone = stateSelect.dataset.empty === 'true' || isSatisfied(citySelect);
submitBtn.disabled = busy || countrySelect.value === '' || !stateDone || !cityDone;
}
countrySelect.addEventListener('change', () => {
resetSelect(stateSelect, 'Select a country first');
resetSelect(citySelect, 'Select a state first');
clearError();
if (countrySelect.value) {
populate(stateSelect, {
resource: 'states',
query: { country: countrySelect.value },
noun: 'states',
placeholder: 'Select a state',
emptyText: 'No states or provinces are listed for this country.',
});
}
updateSubmit();
});
stateSelect.addEventListener('change', () => {
resetSelect(citySelect, 'Select a state first');
clearError();
if (stateSelect.value) {
populate(citySelect, {
resource: 'cities',
query: { state: stateSelect.value, country: countrySelect.value },
noun: 'cities',
placeholder: 'Select a city',
emptyText: 'No cities are listed for this state.',
});
}
updateSubmit();
});
citySelect.addEventListener('change', updateSubmit);
retryBtn.addEventListener('click', () => retryAction?.());
form.addEventListener('submit', (event) => {
event.preventDefault();
if (submitBtn.disabled) return;
const label = (select) => (select.value ? select.selectedOptions[0].text : null);
const selection = {
countryId: countrySelect.value,
country: label(countrySelect),
stateId: stateSelect.value || null,
state: label(stateSelect),
cityId: citySelect.value || null,
city: label(citySelect),
};
console.log('Selected location', selection);
});
populate(countrySelect, {
resource: 'countries',
query: {},
noun: 'countries',
placeholder: 'Select a country',
emptyText: 'No countries are available.',
});
Run node server.js, open http://localhost:3000 and pick a country. The state select shows "Loading states...", the status region announces it, and the select becomes usable once the list arrives. To see the error message and retry button, block /api/locations in the DevTools Network panel and pick another country.
How the Pieces Fit
Stale responses
Each select stores a sequence number in data-seq. If the user changes the country while its states are still loading, resetSelect increments that number, and the old response is dropped when it arrives. Without this, a slow answer for France could fill the list after the user has already switched to Germany.
No debounce
Debouncing is for text inputs that fire on every keystroke. A change event on a select fires once per choice, so the code reacts immediately.
Empty levels
Some territories have no subdivisions in the dataset. When a list comes back empty, the select stays disabled, gets data-empty="true", and the form can still be submitted. The status message explains why the field is unavailable.
Two caches
The browser cache (a Map of promises) avoids repeat requests within a visit. The server cache shares answers across all visitors for 24 hours. A single visitor spends at most three tokens, and with a warm server cache, often none.
Styling and Progressive Enhancement
Browsers style disabled selects with low contrast by default. Add a select:disabled rule that keeps the text readable, and use select[aria-busy="true"] to show a spinner or a subtle background animation. That way, sighted users get the same signal that the live region gives screen reader users. Keep a visible focus outline on every select, and do not hide the labels, even in compact layouts.
If JavaScript fails to load, the form shows a disabled country select with "Loading countries..." and nothing else. For forms where that matters, such as checkout, render a plain text input for the city on the server and let the script replace it once the lists load. It is a small amount of extra work, and it keeps the form usable in every situation.
Deploying the Proxy
The Express server can run anywhere that runs Node.js: a small VPS, a container platform, or next to an existing backend. If you already have a backend in another language, add the same route there instead: validate the resource name, forward only country, state and lang, add the key on the server, and cache successful responses. The in-memory Map is fine for one instance. With several instances, use a shared cache such as Redis, or rely on a CDN in front of the route, since the Cache-Control header is already set on successful answers.
Direct Calls for Prototypes
CORS is open on /v1, so for a quick local prototype you can set const API = 'https://api.countrydataapi.com/v1/select' and add apikey to the query in fetchOptions. Do not ship that version, and do not commit it with a real key. Once a key has appeared in a public repository, rotate it from your account dashboard.
Where to Go Next
- Show dialing codes in a phone field: the countries response already includes
phone_code(see the phone codes guide) - Validate postal codes per country with the address validation guide
- Port the picker to a framework: Angular 19 or Vue 3. Both work with the proxy above.
Get your API key, then read the select endpoint reference for every parameter.