ReactPhoneCountries APITutorial
Phone Dial Code Country Selector in React with Auto-Detection
ApogeoAPI5 min read
What we're building
A complete international phone number input that:
- Shows a searchable dropdown of all countries with flags and dial codes
- Auto-detects the visitor's country from IP to pre-select the right dial code
- Renders a formatted
+{dialCode} {number}output
1. Fetch country list from ApogeoAPI
// hooks/useCountries.ts
import { useEffect, useState } from 'react';
export interface Country {
iso2: string;
commonName: string;
flagUrl: string;
dialCode: string;
}
let countriesCache: Country[] | null = null;
export function useCountries() {
const [countries, setCountries] = useState<Country[]>(countriesCache ?? []);
const [loading, setLoading] = useState(!countriesCache);
useEffect(() => {
if (countriesCache) return;
fetch(`https://api.apogeoapi.com/v1/countries?apikey=${process.env.NEXT_PUBLIC_APOGEO_KEY}`)
.then(r => r.json())
.then(data => {
// Filter to countries with dial codes, sort by name
const filtered = data
.filter((c: Country) => c.dialCode)
.sort((a: Country, b: Country) => a.commonName.localeCompare(b.commonName));
countriesCache = filtered;
setCountries(filtered);
setLoading(false);
});
}, []);
return { countries, loading };
}
// Auto-detect visitor country
let visitorCountryCache: string | null = null;
export async function getVisitorCountry(): Promise<string> {
if (visitorCountryCache) return visitorCountryCache;
try {
const res = await fetch(
`https://api.apogeoapi.com/v1/geo/self?apikey=${process.env.NEXT_PUBLIC_APOGEO_KEY}`
);
const { countryCode } = await res.json();
visitorCountryCache = countryCode ?? 'US';
return visitorCountryCache;
} catch {
return 'US';
}
}
2. PhoneInput component
// components/PhoneInput.tsx
'use client';
import { useEffect, useState, useRef } from 'react';
import { useCountries, getVisitorCountry, type Country } from '@/hooks/useCountries';
interface PhoneInputProps {
value: string;
onChange: (value: string, countryCode: string, dialCode: string) => void;
placeholder?: string;
className?: string;
}
export function PhoneInput({ value, onChange, placeholder = 'Phone number', className = '' }: PhoneInputProps) {
const { countries, loading } = useCountries();
const [selectedCountry, setSelectedCountry] = useState<Country | null>(null);
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const [phone, setPhone] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
// Auto-detect country on mount
useEffect(() => {
if (countries.length === 0) return;
getVisitorCountry().then(iso2 => {
const country = countries.find(c => c.iso2 === iso2) ?? countries[0];
setSelectedCountry(country);
});
}, [countries]);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!dropdownRef.current?.contains(e.target as Node)) {
setOpen(false);
setSearch('');
}
}
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
const filteredCountries = countries.filter(
c => c.commonName.toLowerCase().includes(search.toLowerCase())
|| c.dialCode.includes(search)
|| c.iso2.toLowerCase().includes(search.toLowerCase())
);
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9s-]/g, '');
setPhone(val);
if (selectedCountry) {
onChange(`${selectedCountry.dialCode}${val.replace(/\s/g, '')}`, selectedCountry.iso2, selectedCountry.dialCode);
}
};
const selectCountry = (country: Country) => {
setSelectedCountry(country);
setOpen(false);
setSearch('');
onChange(`${country.dialCode}${phone.replace(/\s/g, '')}`, country.iso2, country.dialCode);
};
return (
<div className={`flex gap-0 rounded-lg border border-gray-300 overflow-visible ${className}`}>
{/* Country Selector */}
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-3 py-2 bg-gray-50 border-r border-gray-300 hover:bg-gray-100 transition-colors rounded-l-lg min-w-[90px]"
disabled={loading}
>
{selectedCountry ? (
<>
<img src={selectedCountry.flagUrl} alt="" width={20} height={15} className="rounded-sm" />
<span className="text-sm font-medium">{selectedCountry.dialCode}</span>
</>
) : (
<span className="text-gray-400 text-sm">+?</span>
)}
<span className="text-gray-400 text-xs">▾</span>
</button>
{open && (
<div className="absolute left-0 top-full mt-1 w-72 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-64 overflow-hidden">
<div className="p-2 border-b">
<input
autoFocus
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search country or code..."
className="w-full px-3 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:border-blue-500"
/>
</div>
<div className="overflow-y-auto max-h-48">
{filteredCountries.map(country => (
<button
key={country.iso2}
type="button"
onClick={() => selectCountry(country)}
className="flex items-center gap-3 w-full px-3 py-2 text-sm hover:bg-gray-50 text-left"
>
<img src={country.flagUrl} alt="" width={20} height={15} className="rounded-sm flex-shrink-0" />
<span className="flex-1 truncate">{country.commonName}</span>
<span className="text-gray-400 text-xs">{country.dialCode}</span>
</button>
))}
{filteredCountries.length === 0 && (
<div className="px-3 py-4 text-sm text-gray-400 text-center">No results</div>
)}
</div>
</div>
)}
</div>
{/* Phone Number Input */}
<input
type="tel"
value={phone}
onChange={handlePhoneChange}
placeholder={placeholder}
className="flex-1 px-3 py-2 text-sm focus:outline-none rounded-r-lg"
/>
</div>
);
}
// Usage:
// <PhoneInput
// value={phone}
// onChange={(full, iso2, dialCode) => setPhone(full)}
// placeholder="Phone number"
// />
3. Display full formatted number
// Show formatted: +1 (555) 123-4567 or +44 7911 123456
function formatPhonePreview(dialCode: string, phone: string): string {
const digits = phone.replace(/\D/g, '');
if (!digits) return dialCode;
return `${dialCode} ${digits}`;
}
// In a form:
const [phone, setPhone] = useState('');
const [countryIso, setCountryIso] = useState('US');
const [dialCode, setDialCode] = useState('+1');
function handlePhoneChange(full: string, iso2: string, code: string) {
setPhone(full);
setCountryIso(iso2);
setDialCode(code);
}
// <PhoneInput value={phone} onChange={handlePhoneChange} />
// <p>Full number: {formatPhonePreview(dialCode, phone)}</p>
4. Server-side variant (Next.js)
For SSR, pre-detect the visitor's country in a Server Component and pass it as a prop:
// app/contact/page.tsx (server component)
import { headers } from 'next/headers';
async function getVisitorCountryServer(): Promise<string> {
const headersList = headers();
const ip = headersList.get('x-forwarded-for')?.split(',')[0] ?? '127.0.0.1';
try {
const res = await fetch(
`https://api.apogeoapi.com/v1/geo/${ip}?apikey=${process.env.APOGEO_KEY}`,
{ next: { revalidate: 3600 } }
);
const { countryCode } = await res.json();
return countryCode ?? 'US';
} catch {
return 'US';
}
}
export default async function ContactPage() {
const country = await getVisitorCountryServer();
return (
<main>
<h1>Contact</h1>
<ContactForm defaultCountry={country} /> {/* pass to client component */}
</main>
);
}
API docs: api.apogeoapi.com/api/docs. Free tier: app.apogeoapi.com/register.
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key