Building a Country Selector Dropdown

javascript
// Get all countries
const response = await fetch(
	"https://worlddataapi.com/v1/countries?per_page=100",
	{ headers: { "X-API-Key": "YOUR_API_KEY" } },
);
const data = await response.json();

console.log(data.data[0]);
// {
//   "code": "AD",
//   "name": "Andorra",
//   "code_alpha3": "AND",
//   ...
// }
python
import requests

response = requests.get(
    "https://worlddataapi.com/v1/countries?per_page=100",
    headers={"X-API-Key": "YOUR_API_KEY"}
)
data = response.json()

print(data["data"][0])
# {
#   "code": "AD",
#   "name": "Andorra",
#   "code_alpha3": "AND",
#   ...
# }
bash
curl -X GET "https://worlddataapi.com/v1/countries?per_page=100" \
  -H "X-API-Key: YOUR_API_KEY"

Country selectors appear on almost every international form. This guide covers building one that handles localization, search, flags, and accessibility.

The Challenge#

Country selectors seem straightforward until you consider international users:

  • Localization: Users expect country names in their own language. "Germany" should display as "Allemagne" for French users or "Deutschland" for German users.

  • Sorting: Alphabetical sorting breaks when localized names change the expected order.

  • Flags: Emoji flags render inconsistently across platforms; some systems show two-letter codes instead.

  • Search: Users may search by English name, localized name, or country code.

  • Accessibility: 249 options require keyboard navigation and screen reader support.

Hardcoding a country list creates maintenance burden and ignores these requirements. An API-driven approach provides standardized data with localization support built in.

Prerequisites#

Before starting, ensure you have:

  • A World Data API key (get one at worlddataapi.com)

  • Node.js 18+ or a modern browser environment

  • Basic familiarity with React (examples use React, but concepts apply to any framework)

Basic Implementation#

Fetching Country Data#

javascript
async function fetchCountries() {
	const countries = [];
	let page = 1;
	let hasMore = true;

	while (hasMore) {
		const response = await fetch(
			`https://worlddataapi.com/v1/countries?page=${page}&per_page=100`,
			{ headers: { "X-API-Key": API_KEY } },
		);

		const data = await response.json();
		countries.push(...data.data);

		hasMore = data.pagination.page < data.pagination.total_pages;
		page++;
	}

	return countries;
}

// The API returns 249 countries — typically 3 pages at 100 per page
const countries = await fetchCountries();

Simple Select#

jsx
function CountrySelect({ value, onChange, countries }) {
	return (
		<select
			value={value}
			onChange={(e) => onChange(e.target.value)}
			aria-label="Select country"
		>
			<option value="">Select a country...</option>
			{countries.map((country) => (
				<option key={country.code} value={country.code}>
					{country.name}
				</option>
			))}
		</select>
	);
}

Enhanced Country Selector#

For 249 options, search is essential:

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

function SearchableCountrySelect({ value, onChange, countries }) {
	const [search, setSearch] = useState("");
	const [isOpen, setIsOpen] = useState(false);

	const filtered = useMemo(() => {
		if (!search) return countries;

		const query = search.toLowerCase();
		return countries.filter(
			(c) =>
				c.name.toLowerCase().includes(query) ||
				c.code.toLowerCase() === query ||
				c.code_alpha3.toLowerCase() === query,
		);
	}, [countries, search]);

	const selected = countries.find((c) => c.code === value);

	return (
		<div className="country-select">
			<button
				type="button"
				className="select-trigger"
				onClick={() => setIsOpen(!isOpen)}
				aria-expanded={isOpen}
				aria-haspopup="listbox"
			>
				{selected ? selected.name : "Select a country..."}
			</button>

			{isOpen && (
				<div className="select-dropdown">
					<input
						type="text"
						className="search-input"
						placeholder="Search countries..."
						value={search}
						onChange={(e) => setSearch(e.target.value)}
						autoFocus
					/>

					<ul role="listbox" className="country-list">
						{filtered.map((country) => (
							<li
								key={country.code}
								role="option"
								aria-selected={country.code === value}
								onClick={() => {
									onChange(country.code);
									setIsOpen(false);
									setSearch("");
								}}
							>
								{country.name}
							</li>
						))}
					</ul>
				</div>
			)}
		</div>
	);
}

With Flags#

Use country code to display flag emoji:

javascript
function countryCodeToFlag(code) {
	// Convert country code to flag emoji
	// Each letter is offset to regional indicator symbol
	const codePoints = code
		.toUpperCase()
		.split("")
		.map((char) => 127397 + char.charCodeAt(0));

	return String.fromCodePoint(...codePoints);
}

console.log(countryCodeToFlag("US")); // US flag emoji
console.log(countryCodeToFlag("JP")); // JP flag emoji
console.log(countryCodeToFlag("DE")); // DE flag emoji

Incorporate into the selector:

jsx
function CountryOption({ country, isSelected, onClick }) {
	return (
		<li
			role="option"
			aria-selected={isSelected}
			onClick={onClick}
			className={`country-option ${isSelected ? "selected" : ""}`}
		>
			<span className="flag">{countryCodeToFlag(country.code)}</span>
			<span className="name">{country.name}</span>
			<span className="code">{country.code}</span>
		</li>
	);
}

Note: Flag emoji may not display correctly on all systems (particularly older Windows versions). Consider using flag images as fallback:

jsx
function Flag({ code, size = 24 }) {
	// Use a CDN like flagcdn.com or country-flags
	const src = `https://flagcdn.com/w${size}/${code.toLowerCase()}.png`;

	return (
		<img
			src={src}
			alt={`${code} flag`}
			width={size}
			height={Math.round(size * 0.75)}
			loading="lazy"
		/>
	);
}

Sorting and Grouping#

Alphabetically by Name#

javascript
const sorted = [...countries].sort((a, b) => a.name.localeCompare(b.name));

By Continent#

jsx
function GroupedCountrySelect({ countries, value, onChange }) {
	const grouped = countries.reduce((acc, country) => {
		const continent = country.continent;
		if (!acc[continent]) acc[continent] = [];
		acc[continent].push(country);
		return acc;
	}, {});

	// Sort continents and countries within
	const sortedGroups = Object.entries(grouped)
		.sort(([a], [b]) => a.localeCompare(b))
		.map(([continent, countries]) => [
			continent,
			countries.sort((a, b) => a.name.localeCompare(b.name)),
		]);

	return (
		<select value={value} onChange={(e) => onChange(e.target.value)}>
			<option value="">Select a country...</option>
			{sortedGroups.map(([continent, countries]) => (
				<optgroup key={continent} label={continent}>
					{countries.map((country) => (
						<option key={country.code} value={country.code}>
							{country.name}
						</option>
					))}
				</optgroup>
			))}
		</select>
	);
}

Common Countries First#

Put frequently selected countries at the top:

javascript
const COMMON_COUNTRIES = [
	"US",
	"GB",
	"CA",
	"AU",
	"DE",
	"FR",
	"JP",
	"IN",
	"BR",
	"MX",
];

function sortWithCommonFirst(countries) {
	const common = [];
	const others = [];

	for (const country of countries) {
		if (COMMON_COUNTRIES.includes(country.code)) {
			common.push(country);
		} else {
			others.push(country);
		}
	}

	// Sort common by predefined order
	common.sort(
		(a, b) =>
			COMMON_COUNTRIES.indexOf(a.code) - COMMON_COUNTRIES.indexOf(b.code),
	);

	// Sort others alphabetically
	others.sort((a, b) => a.name.localeCompare(b.name));

	return { common, others };
}
jsx
function CountrySelectWithCommon({ countries, value, onChange }) {
	const { common, others } = sortWithCommonFirst(countries);

	return (
		<select value={value} onChange={(e) => onChange(e.target.value)}>
			<option value="">Select a country...</option>
			<optgroup label="Common">
				{common.map((c) => (
					<option key={c.code} value={c.code}>
						{c.name}
					</option>
				))}
			</optgroup>
			<optgroup label="All Countries">
				{others.map((c) => (
					<option key={c.code} value={c.code}>
						{c.name}
					</option>
				))}
			</optgroup>
		</select>
	);
}

Localization#

Country names should display in the user's language. The World Data API returns localized names when you specify an Accept-Language header.

Fetching Localized Names#

javascript
async function fetchLocalizedCountries(locale = "en") {
	const countries = [];
	let page = 1;
	let hasMore = true;

	while (hasMore) {
		const response = await fetch(
			`https://worlddataapi.com/v1/countries?page=${page}&per_page=100`,
			{
				headers: {
					"X-API-Key": API_KEY,
					"Accept-Language": locale,
				},
			},
		);

		const data = await response.json();
		countries.push(...data.data);

		hasMore = data.pagination.page < data.pagination.total_pages;
		page++;
	}

	return countries;
}

// Fetch countries with French names
const frenchCountries = await fetchLocalizedCountries("fr");
// Germany appears as "Allemagne", Japan as "Japon", etc.
python
import requests

def fetch_localized_countries(locale="en"):
    countries = []
    page = 1
    has_more = True

    while has_more:
        response = requests.get(
            f"https://worlddataapi.com/v1/countries?page={page}&per_page=100",
            headers={
                "X-API-Key": API_KEY,
                "Accept-Language": locale
            }
        )
        data = response.json()
        countries.extend(data["data"])

        has_more = data["pagination"]["page"] < data["pagination"]["total_pages"]
        page += 1

    return countries

# Fetch countries with German names
german_countries = fetch_localized_countries("de")

Sorting Localized Names#

When displaying localized names, use locale-aware sorting:

javascript
function sortCountriesByLocale(countries, locale) {
	return [...countries].sort((a, b) =>
		a.name.localeCompare(b.name, locale, { sensitivity: "base" }),
	);
}

// Sort French country names correctly
const sorted = sortCountriesByLocale(frenchCountries, "fr");

Caching Multiple Locales#

If your application supports multiple languages, cache each locale separately:

javascript
const CACHE_PREFIX = "countries_";
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days

async function getLocalizedCountries(locale) {
	const cacheKey = `${CACHE_PREFIX}${locale}`;

	// Check cache
	const cached = localStorage.getItem(cacheKey);
	if (cached) {
		const { data, timestamp } = JSON.parse(cached);
		if (Date.now() - timestamp < CACHE_TTL) {
			return data;
		}
	}

	// Fetch fresh
	const countries = await fetchLocalizedCountries(locale);

	// Cache
	localStorage.setItem(
		cacheKey,
		JSON.stringify({
			data: countries,
			timestamp: Date.now(),
		}),
	);

	return countries;
}

React Hook with Localization#

javascript
function useCountries(locale = "en") {
	const [countries, setCountries] = useState([]);
	const [loading, setLoading] = useState(true);
	const [error, setError] = useState(null);

	useEffect(() => {
		setLoading(true);
		getLocalizedCountries(locale)
			.then((data) => {
				const sorted = sortCountriesByLocale(data, locale);
				setCountries(sorted);
			})
			.catch(setError)
			.finally(() => setLoading(false));
	}, [locale]);

	return { countries, loading, error };
}

// Usage
function App() {
	const { i18n } = useTranslation(); // or your i18n library
	const { countries, loading } = useCountries(i18n.language);

	// Countries are now in the user's language
}

Phone Code Integration#

For phone number inputs, show calling codes:

jsx
function PhoneCountrySelect({ countries, value, onChange }) {
	return (
		<select
			value={value}
			onChange={(e) => onChange(e.target.value)}
			className="phone-country-select"
		>
			<option value="">Select...</option>
			{countries.map((country) => (
				<option key={country.code} value={country.code}>
					{countryCodeToFlag(country.code)} {country.name}
				</option>
			))}
		</select>
	);
}

// Pair with phone input
function PhoneInput({ country, phoneNumber, onCountryChange, onPhoneChange }) {
	return (
		<div className="phone-input">
			<PhoneCountrySelect
				countries={countries}
				value={country}
				onChange={onCountryChange}
			/>
			<input
				type="tel"
				value={phoneNumber}
				onChange={(e) => onPhoneChange(e.target.value)}
				placeholder="Phone number"
			/>
		</div>
	);
}

Accessibility#

Keyboard Navigation#

jsx
function AccessibleCountrySelect({ countries, value, onChange }) {
	const [isOpen, setIsOpen] = useState(false);
	const [focusIndex, setFocusIndex] = useState(-1);
	const listRef = useRef(null);

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

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

	return (
		<div
			className="country-select"
			onKeyDown={handleKeyDown}
			role="combobox"
			aria-expanded={isOpen}
			aria-haspopup="listbox"
		>
			{/* ... trigger and dropdown */}
		</div>
	);
}

Screen Reader Support#

jsx
<ul role="listbox" aria-label="Countries" ref={listRef}>
	{countries.map((country, i) => (
		<li
			key={country.code}
			role="option"
			id={`country-${country.code}`}
			aria-selected={country.code === value}
			tabIndex={focusIndex === i ? 0 : -1}
		>
			<span aria-hidden="true">{countryCodeToFlag(country.code)}</span>
			{country.name}
		</li>
	))}
</ul>

Caching#

Country data rarely changes. Cache aggressively:

javascript
const CACHE_KEY = "countries";
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days

async function getCountries() {
	// Check cache
	const cached = localStorage.getItem(CACHE_KEY);
	if (cached) {
		const { data, timestamp } = JSON.parse(cached);
		if (Date.now() - timestamp < CACHE_TTL) {
			return data;
		}
	}

	// Fetch fresh
	const countries = await fetchCountries();

	// Cache
	localStorage.setItem(
		CACHE_KEY,
		JSON.stringify({
			data: countries,
			timestamp: Date.now(),
		}),
	);

	return countries;
}

React Hook#

Encapsulate everything in a reusable hook:

javascript
function useCountries() {
	const [countries, setCountries] = useState([]);
	const [loading, setLoading] = useState(true);
	const [error, setError] = useState(null);

	useEffect(() => {
		getCountries()
			.then(setCountries)
			.catch(setError)
			.finally(() => setLoading(false));
	}, []);

	const getByCode = useCallback(
		(code) => {
			return countries.find(
				(c) => c.code === code || c.code_alpha3 === code,
			);
		},
		[countries],
	);

	const search = useCallback(
		(query) => {
			if (!query) return countries;
			const q = query.toLowerCase();
			return countries.filter(
				(c) =>
					c.name.toLowerCase().includes(q) ||
					c.code.toLowerCase() === q,
			);
		},
		[countries],
	);

	return {
		countries,
		loading,
		error,
		getByCode,
		search,
	};
}

Complete Component#

jsx
function CountrySelector({
	value,
	onChange,
	placeholder = "Select a country...",
}) {
	const { countries, loading, search } = useCountries();
	const [isOpen, setIsOpen] = useState(false);
	const [query, setQuery] = useState("");
	const [focusIndex, setFocusIndex] = useState(-1);

	const filtered = useMemo(() => {
		const results = search(query);
		const { common, others } = sortWithCommonFirst(results);
		return query ? results : [...common, ...others];
	}, [search, query]);

	const selected = countries.find((c) => c.code === value);

	if (loading) {
		return (
			<select disabled>
				<option>Loading countries...</option>
			</select>
		);
	}

	return (
		<div className="country-selector">
			<button
				type="button"
				className="selector-trigger"
				onClick={() => setIsOpen(!isOpen)}
				aria-expanded={isOpen}
			>
				{selected ? (
					<>
						<span className="flag">
							{countryCodeToFlag(selected.code)}
						</span>
						<span className="name">{selected.name}</span>
					</>
				) : (
					<span className="placeholder">{placeholder}</span>
				)}
				<span className="arrow"></span>
			</button>

			{isOpen && (
				<div className="selector-dropdown">
					<input
						type="text"
						className="search"
						placeholder="Search..."
						value={query}
						onChange={(e) => setQuery(e.target.value)}
						autoFocus
					/>

					<ul className="options" role="listbox">
						{filtered.length === 0 ? (
							<li className="no-results">No countries found</li>
						) : (
							filtered.map((country, i) => (
								<li
									key={country.code}
									role="option"
									aria-selected={country.code === value}
									className={`option ${i === focusIndex ? "focused" : ""}`}
									onClick={() => {
										onChange(country.code);
										setIsOpen(false);
										setQuery("");
									}}
								>
									<span className="flag">
										{countryCodeToFlag(country.code)}
									</span>
									<span className="name">{country.name}</span>
									<span className="code">{country.code}</span>
								</li>
							))
						)}
					</ul>
				</div>
			)}
		</div>
	);
}

Common Pitfalls#

Hardcoding Country Lists#

Avoid copying a static country list into your codebase. Countries change names (Swaziland became Eswatini in 2018), new countries are recognized, and ISO codes are occasionally updated. An API-driven approach ensures your selector stays current.

Ignoring Flag Display Issues#

Flag emoji display inconsistently across operating systems. Windows 10 and earlier shows two-letter country codes instead of flag images. Always provide a fallback using flag images from a CDN:

javascript
function FlagWithFallback({ code, size = 24 }) {
	const [useImage, setUseImage] = useState(false);

	// Detect Windows (where flag emoji may not render)
	useEffect(() => {
		if (navigator.platform.indexOf("Win") > -1) {
			setUseImage(true);
		}
	}, []);

	if (useImage) {
		return (
			<img
				src={`https://flagcdn.com/w${size}/${code.toLowerCase()}.png`}
				alt={`${code} flag`}
				width={size}
				height={Math.round(size * 0.75)}
			/>
		);
	}

	return <span>{countryCodeToFlag(code)}</span>;
}

Sorting Without Locale Awareness#

The default JavaScript sort() produces incorrect results for non-ASCII characters:

javascript
// Wrong: ignores locale-specific sorting
countries.sort((a, b) => (a.name > b.name ? 1 : -1));

// Correct: respects locale sorting rules
countries.sort((a, b) => a.name.localeCompare(b.name, userLocale));

Not Caching Country Data#

Country data rarely changes. Fetching 249 countries on every page load wastes bandwidth and increases latency. Implement caching with a TTL of at least 24 hours, or longer for production applications (7 days is reasonable).

Missing Accessibility Features#

Screen readers need proper ARIA attributes. Ensure your selector includes:

  • role="listbox" on the options container

  • role="option" on each option

  • aria-selected to indicate the current selection

  • aria-expanded on the trigger button

  • Keyboard navigation support (arrow keys, Enter, Escape)

Assuming English-Only Users#

If your application serves international users, implement localization from the start. Retrofitting localized country names is more difficult than building with localization in mind.

Styling#

css
.country-selector {
	position: relative;
	width: 100%;
	max-width: 320px;
}

.selector-trigger {
	display: flex;
	align-items: center;
	gap: 8px;
	width: 100%;
	padding: 8px 12px;
	border: 1px solid #ccc;
	border-radius: 4px;
	background: white;
	cursor: pointer;
	text-align: left;
}

.selector-trigger .arrow {
	margin-left: auto;
	font-size: 0.75em;
	color: #666;
}

.selector-dropdown {
	position: absolute;
	top: 100%;
	left: 0;
	right: 0;
	margin-top: 4px;
	background: white;
	border: 1px solid #ccc;
	border-radius: 4px;
	box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
	z-index: 100;
}

.selector-dropdown .search {
	width: 100%;
	padding: 8px 12px;
	border: none;
	border-bottom: 1px solid #eee;
	outline: none;
}

.options {
	max-height: 240px;
	overflow-y: auto;
	list-style: none;
	margin: 0;
	padding: 0;
}

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

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

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

.option .code {
	margin-left: auto;
	color: #999;
	font-size: 0.875em;
}

.flag {
	font-size: 1.25em;
}

.no-results {
	padding: 12px;
	color: #666;
	text-align: center;
}

Summary#

Building a country selector that works for international users requires attention to several details:

  • Fetch data from an API rather than hardcoding lists, ensuring your selector stays current as countries change

  • Implement localization using the Accept-Language header to display country names in the user's language

  • Handle flag display with fallbacks for systems that do not render emoji flags

  • Add search functionality to help users find their country quickly among 249 options

  • Support accessibility with proper ARIA attributes and keyboard navigation

  • Cache aggressively since country data rarely changes

The World Data API provides all 249 ISO 3166-1 countries with localized names, ISO codes (alpha-2 and alpha-3), and continent data in a single endpoint.

Get your free API key and start building your country selector today. The free tier includes 60 requests per day, enough to develop and test your implementation.

Related guides: