All posts

Build a React/Next.js Address Autocomplete Component for Vietnam

A practical guide to building a reusable Vietnamese address-autocomplete component in React/Next.js: debounce, keyboard navigation, a key-hiding proxy, and wiring /v1/suggest to /v1/place/resolve for clean coordinates.

Almost every checkout, delivery, or sign-up form in Vietnam needs a smart address field. But the way many people build it — drop an <input> and call the API on every keystroke — fails in the places nobody looks: it leaks the API key to the browser, fires a request per character, has no debounce, and never groups keystrokes into one billable session.

This post builds a reusable <AddressAutocomplete> component in React/Next.js with debounce, keyboard navigation, a server-side proxy that hides your key, and the two GoGoDuk endpoints wired together — /v1/suggest for predictions and /v1/place/resolve to get clean coordinates for the address the user picks.

This post focuses on the implementation. If you want the UX design and the reasons to use autocomplete for Vietnamese addresses, read Vietnam Address Autocomplete API first. New to geocoding? Start with What is geocoding?.

Why not call the API straight from the browser

A hand-rolled autocomplete field usually makes four mistakes:

  • It leaks the API key. Calling api.gogoduk.com directly from a component puts X-API-Key in client JavaScript and in the Network tab — anyone can copy it.
  • It spams requests. A request per character adds latency and burns quota without making the dropdown better.
  • No debounce. Fast typing creates a race of overlapping requests and a flickering result list.
  • No billing grouping. Each keystroke is billed as its own lookup instead of one autocomplete session.

The right shape: the browser calls your own Next.js route handler, and that route adds X-API-Key and calls GoGoDuk. The key never leaves the server.

Architecture

Browser (<AddressAutocomplete/>)
  -> Next.js /api/address/suggest   (proxy, adds X-API-Key)
  -> GoGoDuk /v1/suggest            (predictions for input)
  -> user selects a prediction (placeId)
  -> Next.js /api/address/resolve   (proxy)
  -> GoGoDuk /v1/place/resolve      (full record: lat/lon + address)
  -> return a stable value to store / geocode

Proxy suggestions through Next.js

Create app/api/address/suggest/route.ts. The /v1/suggest endpoint requires input of at least 2 characters, accepts lang (default vi), country (VN only), and optional focus.lat/focus.lon to bias results toward a location.

import { NextResponse } from "next/server";
 
const API_URL = process.env.GOGODUK_API_URL ?? "https://api.gogoduk.com";
 
export async function GET(req: Request) {
  const url = new URL(req.url);
  const input = url.searchParams.get("input")?.trim() ?? "";
  // /v1/suggest needs at least 2 characters — short-circuit to avoid waste.
  if (input.length < 2) {
    return NextResponse.json({ predictions: [] });
  }
 
  const upstream = new URL("/v1/suggest", API_URL);
  upstream.searchParams.set("input", input);
  upstream.searchParams.set("lang", "vi");
  upstream.searchParams.set("country", "VN");
 
  // Group keystrokes into one billable session: forward the sessionToken.
  const sessionToken = url.searchParams.get("sessionToken");
  if (sessionToken) upstream.searchParams.set("sessionToken", sessionToken);
 
  const response = await fetch(upstream, {
    cache: "no-store",
    headers: { "X-API-Key": process.env.GOGODUK_API_KEY! },
  });
 
  return NextResponse.json(await response.json(), {
    status: response.status,
  });
}

Store the key in a server-only environment variable:

GOGODUK_API_URL=https://api.gogoduk.com
GOGODUK_API_KEY=gdk_live_your_key

Do not prefix the key with NEXT_PUBLIC_ — that prefix deliberately exposes the value to browser bundles.

The <AddressAutocomplete> component

The component is a controlled <input> that debounces ~250ms before calling the proxy route. Each /v1/suggest response returns a predictions array, where each item has placeId, text, mainText, secondaryText.

"use client";
 
import { useEffect, useId, useRef, useState } from "react";
 
type Prediction = {
  placeId: string;
  text: string;
  mainText: string;
  secondaryText: string;
};
 
export type ResolvedPlace = {
  placeId: string;
  address: string;
  lat: number;
  lon: number;
  district: string | null;
  city: string | null;
};
 
export function AddressAutocomplete({
  onSelect,
}: {
  onSelect: (place: ResolvedPlace) => void;
}) {
  const [query, setQuery] = useState("");
  const [items, setItems] = useState<Prediction[]>([]);
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);
  // One sessionToken per autocomplete session (typing -> selection).
  const sessionToken = useRef(crypto.randomUUID());
  const listboxId = useId();
 
  useEffect(() => {
    const input = query.trim();
    if (input.length < 2) {
      setItems([]);
      return;
    }
 
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      const params = new URLSearchParams({
        input,
        sessionToken: sessionToken.current,
      });
      const res = await fetch(`/api/address/suggest?${params}`, {
        signal: controller.signal,
      });
      const data = await res.json();
      setItems(data.predictions ?? []);
      setOpen(true);
      setActive(-1);
    }, 250);
 
    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);
 
  async function select(prediction: Prediction) {
    setQuery(prediction.text);
    setOpen(false);
 
    const params = new URLSearchParams({
      id: prediction.placeId,
      sessionToken: sessionToken.current,
    });
    const res = await fetch(`/api/address/resolve?${params}`);
    const { result } = await res.json();
 
    onSelect({
      placeId: result.placeId,
      address: result.address,
      lat: result.lat,
      lon: result.lon, // note: lon, NOT lng
      district: result.district ?? null,
      city: result.city ?? null,
    });
 
    // Session is over -> mint a new token for the next one.
    sessionToken.current = crypto.randomUUID();
  }
 
  return (
    <div style={{ position: "relative" }}>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={(e) => onKeyDown(e, { items, active, setActive, setOpen, select })}
        role="combobox"
        aria-expanded={open}
        aria-controls={listboxId}
        aria-activedescendant={active >= 0 ? `${listboxId}-${active}` : undefined}
        autoComplete="off"
        placeholder="Enter an address…"
      />
      {open && items.length > 0 && (
        <ul id={listboxId} role="listbox">
          {items.map((item, i) => (
            <li
              key={item.placeId}
              id={`${listboxId}-${i}`}
              role="option"
              aria-selected={i === active}
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => select(item)}
            >
              <strong>{item.mainText}</strong>
              <span>{item.secondaryText}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Keyboard navigation and a11y

A good autocomplete field must be fully keyboard-usable. Keep the key handling in a small helper:

type KeyCtx = {
  items: Prediction[];
  active: number;
  setActive: (n: number) => void;
  setOpen: (v: boolean) => void;
  select: (p: Prediction) => void;
};
 
function onKeyDown(e: React.KeyboardEvent, ctx: KeyCtx) {
  const { items, active, setActive, setOpen, select } = ctx;
  if (!items.length) return;
 
  switch (e.key) {
    case "ArrowDown":
      e.preventDefault();
      setActive(Math.min(active + 1, items.length - 1));
      break;
    case "ArrowUp":
      e.preventDefault();
      setActive(Math.max(active - 1, 0));
      break;
    case "Enter":
      if (active >= 0) {
        e.preventDefault();
        select(items[active]);
      }
      break;
    case "Escape":
      setOpen(false);
      break;
  }
}

The role="combobox", aria-expanded, aria-activedescendant, and role="option" attributes above let screen readers understand the suggestion list — don't skip them.

Resolve the selected place

When the user picks a prediction, turn the placeId into a full record through a second proxy route. Create app/api/address/resolve/route.ts:

import { NextResponse } from "next/server";
 
const API_URL = process.env.GOGODUK_API_URL ?? "https://api.gogoduk.com";
 
export async function GET(req: Request) {
  const url = new URL(req.url);
  const id = url.searchParams.get("id")?.trim();
  if (!id) {
    return NextResponse.json({ error: "missing id" }, { status: 400 });
  }
 
  const upstream = new URL("/v1/place/resolve", API_URL);
  upstream.searchParams.set("id", id);
  upstream.searchParams.set("lang", "vi");
 
  // Reuse the suggest session's token so billing groups correctly.
  const sessionToken = url.searchParams.get("sessionToken");
  if (sessionToken) upstream.searchParams.set("sessionToken", sessionToken);
 
  const response = await fetch(upstream, {
    cache: "no-store",
    headers: { "X-API-Key": process.env.GOGODUK_API_KEY! },
  });
 
  return NextResponse.json(await response.json(), {
    status: response.status,
  });
}

The /v1/place/resolve response returns a result with placeId, name, address, lat, lon, district, city, country. A few things that trip people up:

  • The coordinate is lon, not lng — using the wrong field name is the most common bug.
  • district (ward/district) and city may be null — always handle the missing case.
  • Only resolve after the user selects, never on every keystroke — it's slow and burns quota.

Return a stable value to store

The goal isn't a pretty dropdown — it's a stable record you can save against an order: the display text, the placeId, and the lat/lon. Once you have coordinates, your checkout can compute distance-based shipping fees, assign a delivery zone, or determine the ward — without making the user type again.

function CheckoutForm() {
  const [place, setPlace] = useState<ResolvedPlace | null>(null);
 
  return (
    <form>
      <AddressAutocomplete onSelect={setPlace} />
      {place && (
        <input type="hidden" name="placeId" value={place.placeId} />
      )}
      {/* place.lat / place.lon are ready for fee math or zone assignment */}
    </form>
  );
}

Next steps

You now have a Vietnamese address-autocomplete component that's safe (key hidden), efficient (debounce + one billing session), and keyboard-usable. From the lat/lon you get, go further:

Want to use GoGoDuk?

Free forever — 100 requests/day per account, no credit card. Higher limits on request.

Sign up →