// 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",
// ...
// }
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",
# ...
# }
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#
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#
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#
With Search#
For 249 options, search is essential:
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:
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:
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:
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#
const sorted = [...countries].sort((a, b) => a.name.localeCompare(b.name));
By Continent#
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:
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 };
}
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#
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.
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:
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:
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#
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:
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#
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#
<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:
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:
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#
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:
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:
// 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 containerrole="option"on each optionaria-selectedto indicate the current selectionaria-expandedon the trigger buttonKeyboard 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#
.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-Languageheader to display country names in the user's languageHandle 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:
Implementing City Autocomplete Search — Add city selection to complement your country picker
Working with ISO Country and Region Codes — Understand alpha-2, alpha-3, and regional codes
Building a Power Adapter Checker for Travel Apps — Use country data for travel features