A bad address form kills conversion. Replace four free-text fields with cascading dropdowns that cannot be filled in wrong.
The checkout form is where money leaks. Every free-text field is a chance for the user to mistype, and every wrong address is a parcel that comes back to the warehouse. The fix is not stricter validation — it is making the mistake impossible.
Four levels, four dropdowns
Indonesia's administrative regions form a clean tree: province → regency/city → district → village. Each dropdown only shows children of the previous choice, so invalid combinations never appear on screen.
# 34 provinces GET /wilayah/provinces # regencies within a province GET /wilayah/regencies?province=32 # districts within a regency GET /wilayah/districts?regency=3204 # villages within a district GET /wilayah/villages?district=3204010
Implementing the cascade
The pattern is always the same: when a level changes, fetch the next level and clear everything below it.
// Call through your own backend — never put the API key in the browser.
async function get(path) {
const res = await fetch('/api/proxy' + path); // your backend forwards to the API
return (await res.json()).data;
}
async function loadProvinces() {
fill('province', await get('/wilayah/provinces'));
}
document.getElementById('province').onchange = async (e) => {
clear(['regency', 'district', 'village']);
fill('regency', await get('/wilayah/regencies?province=' + e.target.value));
};
document.getElementById('regency').onchange = async (e) => {
clear(['district', 'village']);
fill('district', await get('/wilayah/districts?regency=' + e.target.value));
};
document.getElementById('district').onchange = async (e) => {
clear(['village']);
fill('village', await get('/wilayah/villages?district=' + e.target.value));
};
function fill(id, rows) {
const el = document.getElementById(id);
el.innerHTML = '<option value="">Select…</option>' +
rows.map((r) => `<option value="${r.code}">${r.name}</option>`).join('');
el.disabled = false;
}
function clear(ids) {
ids.forEach((id) => {
const el = document.getElementById(id);
el.innerHTML = '<option value="">Select…</option>';
el.disabled = true;
});
}/api/proxy… on your own backend, not our API directly. If the frontend calls us directly, your API key is visible to anyone who opens DevTools.Store the code, not the name
This is the most consequential database decision in this article. Store region codes, not name strings:
CREATE TABLE addresses ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, province_code TEXT NOT NULL, -- "32" regency_code TEXT NOT NULL, -- "3204" district_code TEXT NOT NULL, -- "3204010" village_code TEXT NOT NULL, -- "3204010001" detail TEXT NOT NULL, -- "Jl. Merdeka No. 1, RT 03/RW 05" postal_code TEXT );
Codes are stable and joinable. Names are not: "Kab. Bandung", "KABUPATEN BANDUNG", and "Bandung (Kab)" are three different strings pointing at the same place, and your reports will count them as three regions.
Search, for users in a hurry
Four dropdowns are accurate but slow for a user who already knows where they live. Give them a shortcut with the search endpoint, which cuts across every level at once:
GET /wilayah/search?q=menteng&limit=5 # → district:MENTENG, village:MENTENG DALAM, village:MENTENG ATAS …
The best pattern: a search box on top, the cascade as a fallback beneath it. Users who know their address type three letters and are done; unsure users walk the tree.
The measurable payoff
After replacing free-text fields with a cascade, two metrics are worth watching: the share of parcels that fail delivery on address, and average time-to-complete on the form. The first usually drops sharply; the second often drops too, because picking from a list beats typing.
Ready to build?
Create a free account and get your API key — 5,000 free hits a month, and two endpoints that need no key at all.