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