Tutorial

Building International Address Forms with API Data

March 1, 2025 10 min read CountryDataAPI Team

How to build dynamic address forms that cascade from country → state → city using CountryDataAPI. Works with React, Vue, and vanilla JS.

International address forms are notoriously difficult to get right. A static hardcoded list of countries and states quickly becomes outdated and fails international users. The solution is to build dynamic cascading dropdowns powered by a live API. This guide shows you how to build a complete country → state → city form using CountryDataAPI, with examples for React, Vue, and vanilla JavaScript.

The Cascading Form Pattern

The pattern is straightforward:

  1. Load all countries on page mount
  2. When the user selects a country, load its states
  3. When the user selects a state, load its cities
  4. Optionally, when the user enters a zip code, auto-fill city and state

API Endpoints Used

We'll use three CountryDataAPI endpoints:

  • GET /v1/countries/all — All countries
  • GET /v1/states/by-country?country=US — States for a country
  • GET /v1/cities/by-state?country=US&state=NY — Cities for a state

React Implementation

The API Service

First, create a centralized API service:

// services/geoApi.js
const BASE_URL = 'https://api.countrydataapi.com/v1';
const API_KEY = process.env.REACT_APP_COUNTRY_API_KEY;

const headers = { 'x-api-key': API_KEY };

export const geoApi = {
  getCountries: () =>
    fetch(`${BASE_URL}/countries/all`, { headers }).then(r => r.json()),

  getStatesByCountry: (countryCode) =>
    fetch(`${BASE_URL}/states/by-country?country=${countryCode}`, { headers })
      .then(r => r.json()),

  getCitiesByState: (countryCode, stateCode) =>
    fetch(`${BASE_URL}/cities/by-state?country=${countryCode}&state=${stateCode}`, { headers })
      .then(r => r.json()),
};

The Address Form Component

import React, { useState, useEffect } from 'react';
import { geoApi } from './services/geoApi';

const AddressForm = ({ onSubmit }) => {
  const [countries, setCountries] = useState([]);
  const [states, setStates] = useState([]);
  const [cities, setCities] = useState([]);

  const [address, setAddress] = useState({
    country: '',
    state: '',
    city: '',
    street: '',
    zipCode: ''
  });

  const [loading, setLoading] = useState({
    countries: true,
    states: false,
    cities: false
  });

  // Load countries on mount
  useEffect(() => {
    geoApi.getCountries().then(data => {
      setCountries(data.sort((a, b) => a.name.localeCompare(b.name)));
      setLoading(prev => ({ ...prev, countries: false }));
    });
  }, []);

  // Load states when country changes
  useEffect(() => {
    if (!address.country) {
      setStates([]);
      setCities([]);
      return;
    }
    setLoading(prev => ({ ...prev, states: true }));
    setAddress(prev => ({ ...prev, state: '', city: '' }));
    geoApi.getStatesByCountry(address.country).then(data => {
      setStates(data.sort((a, b) => a.name.localeCompare(b.name)));
      setLoading(prev => ({ ...prev, states: false }));
    });
  }, [address.country]);

  // Load cities when state changes
  useEffect(() => {
    if (!address.state || !address.country) {
      setCities([]);
      return;
    }
    setLoading(prev => ({ ...prev, cities: true }));
    setAddress(prev => ({ ...prev, city: '' }));
    geoApi.getCitiesByState(address.country, address.state).then(data => {
      setCities(data.sort((a, b) => a.name.localeCompare(b.name)));
      setLoading(prev => ({ ...prev, cities: false }));
    });
  }, [address.state]);

  const handleChange = (field) => (e) => {
    setAddress(prev => ({ ...prev, [field]: e.target.value }));
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); onSubmit(address); }}>
      <div className="form-row">
        <label>Country *</label>
        <select
          value={address.country}
          onChange={handleChange('country')}
          disabled={loading.countries}
          required
        >
          <option value="">
            {loading.countries ? 'Loading...' : '-- Select Country --'}
          </option>
          {countries.map(c => (
            <option key={c.iso2} value={c.iso2}>{c.name}</option>
          ))}
        </select>
      </div>

      <div className="form-row">
        <label>State / Province</label>
        <select
          value={address.state}
          onChange={handleChange('state')}
          disabled={!address.country || loading.states}
        >
          <option value="">
            {loading.states ? 'Loading...' : '-- Select State --'}
          </option>
          {states.map(s => (
            <option key={s.state_code} value={s.state_code}>
              {s.name}
            </option>
          ))}
        </select>
      </div>

      <div className="form-row">
        <label>City</label>
        <select
          value={address.city}
          onChange={handleChange('city')}
          disabled={!address.state || loading.cities}
        >
          <option value="">
            {loading.cities ? 'Loading...' : '-- Select City --'}
          </option>
          {cities.map(c => (
            <option key={c.name} value={c.name}>{c.name}</option>
          ))}
        </select>
      </div>

      <div className="form-row">
        <label>Street Address *</label>
        <input
          type="text"
          value={address.street}
          onChange={handleChange('street')}
          required
        />
      </div>

      <button type="submit">Save Address</button>
    </form>
  );
};

Vue 3 Implementation

<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>Country</label>
      <select v-model="address.country" @change="onCountryChange">
        <option value="">{{ countriesLoading ? 'Loading...' : 'Select Country' }}</option>
        <option v-for="c in countries" :key="c.iso2" :value="c.iso2">
          {{ c.name }}
        </option>
      </select>
    </div>

    <div class="form-group">
      <label>State</label>
      <select v-model="address.state" :disabled="!address.country" @change="onStateChange">
        <option value="">{{ statesLoading ? 'Loading...' : 'Select State' }}</option>
        <option v-for="s in states" :key="s.state_code" :value="s.state_code">
          {{ s.name }}
        </option>
      </select>
    </div>

    <div class="form-group">
      <label>City</label>
      <select v-model="address.city" :disabled="!address.state">
        <option value="">{{ citiesLoading ? 'Loading...' : 'Select City' }}</option>
        <option v-for="c in cities" :key="c.name" :value="c.name">
          {{ c.name }}
        </option>
      </select>
    </div>
  </form>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue';

const API_KEY = import.meta.env.VITE_COUNTRY_API_KEY;
const BASE_URL = 'https://api.countrydataapi.com/v1';
const fetchGeo = (path) =>
  fetch(`${BASE_URL}${path}`, { headers: { 'x-api-key': API_KEY } }).then(r => r.json());

const address = reactive({ country: '', state: '', city: '' });
const countries = ref([]);
const states = ref([]);
const cities = ref([]);
const countriesLoading = ref(true);
const statesLoading = ref(false);
const citiesLoading = ref(false);

onMounted(async () => {
  countries.value = (await fetchGeo('/countries/all')).sort((a, b) => a.name.localeCompare(b.name));
  countriesLoading.value = false;
});

const onCountryChange = async () => {
  address.state = ''; address.city = '';
  states.value = []; cities.value = [];
  if (!address.country) return;
  statesLoading.value = true;
  states.value = (await fetchGeo(`/states/by-country?country=${address.country}`))
    .sort((a, b) => a.name.localeCompare(b.name));
  statesLoading.value = false;
};

const onStateChange = async () => {
  address.city = ''; cities.value = [];
  if (!address.state) return;
  citiesLoading.value = true;
  cities.value = (await fetchGeo(`/cities/by-state?country=${address.country}&state=${address.state}`))
    .sort((a, b) => a.name.localeCompare(b.name));
  citiesLoading.value = false;
};
</script>

Accessibility Best Practices

International address forms must be accessible:

  • Add aria-busy="true" to selects while loading
  • Use aria-label or <label> for all inputs
  • Announce state changes to screen readers with aria-live="polite"
  • Support keyboard navigation fully

Performance Optimization

For high-traffic apps, pre-load all countries at app startup and cache states/cities in memory:

// Cache strategy
const geoCache = {};
const fetchWithCache = async (url) => {
  if (geoCache[url]) return geoCache[url];
  const data = await fetch(url, { headers: { 'x-api-key': API_KEY } }).then(r => r.json());
  geoCache[url] = data;
  return data;
};

Next Steps

You now have a full cascading address form. To extend it further:

  • Add zip code auto-fill with the zip code lookup guide
  • Add phone number fields using phone_code from the countries API
  • Validate the address against the zip code endpoint

Related Guides