Tất cả bài viết

Xây dựng component tự động hoàn thành địa chỉ trong React/Next.js cho Việt Nam

Hướng dẫn dựng một component ô nhập địa chỉ Việt Nam tái dùng được trong React/Next.js: debounce, điều hướng bàn phím, proxy giấu API key, và ghép /v1/suggest với /v1/place/resolve để lấy toạ độ chuẩn.

Gần như mọi form checkout, giao hàng hay đăng ký ở Việt Nam đều cần một ô nhập địa chỉ thông minh. Nhưng cách nhiều người làm — đặt một thẻ <input> rồi gọi thẳng API mỗi lần gõ phím — lại sai ở những chỗ ít ai để ý: lộ API key ra trình duyệt, bắn request mỗi ký tự, không debounce, và không gộp các lần gõ thành một phiên billing.

Bài này dựng một component <AddressAutocomplete> tái dùng được trong React/Next.js: có debounce, điều hướng bàn phím, proxy phía server để giấu key, và ghép hai endpoint của GoGoDuk — /v1/suggest để gợi ý và /v1/place/resolve để lấy toạ độ chuẩn cho địa chỉ người dùng chọn.

Bài này tập trung vào phần triển khai code. Nếu bạn cần phần thiết kế trải nghiệm và lý do nên dùng autocomplete cho địa chỉ Việt Nam, đọc trước Vietnam Address Autocomplete API. Còn chưa rõ geocoding là gì thì bắt đầu ở Geocoding là gì?.

Vì sao đừng gọi thẳng API từ trình duyệt

Một ô autocomplete "tự chế" thường mắc bốn lỗi:

  • Lộ API key. Gọi thẳng api.gogoduk.com từ component nghĩa là X-API-Key nằm trong JS phía client và trong tab Network — ai cũng copy được.
  • Spam request. Mỗi ký tự một request làm tăng latency và đốt quota mà không giúp dropdown tốt hơn.
  • Không debounce. Gõ nhanh tạo ra hàng loạt request đua nhau, kết quả nhảy loạn.
  • Không gộp billing. Mỗi lần gõ bị tính như một lượt riêng thay vì một phiên autocomplete.

Cách đúng: trình duyệt gọi route handler của chính bạn trong Next.js; route đó mới gắn X-API-Key và gọi GoGoDuk. Key không bao giờ rời khỏi server.

Kiến trúc

Trình duyệt (<AddressAutocomplete/>)
  -> Next.js /api/address/suggest   (proxy, gắn X-API-Key)
  -> GoGoDuk /v1/suggest            (gợi ý theo input)
  -> người dùng chọn 1 prediction (placeId)
  -> Next.js /api/address/resolve   (proxy)
  -> GoGoDuk /v1/place/resolve      (lấy lat/lon + địa chỉ chuẩn)
  -> trả về giá trị ổn định để lưu / geocode

Proxy gợi ý qua Next.js

Tạo app/api/address/suggest/route.ts. Endpoint /v1/suggest yêu cầu input tối thiểu 2 ký tự, nhận lang (mặc định vi), country (chỉ VN), và tuỳ chọn focus.lat/focus.lon để ưu tiên kết quả gần một vị trí.

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 cần tối thiểu 2 ký tự — chặn sớm để khỏi gọi thừa.
  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");
 
  // Gộp các lần gõ thành một phiên billing: truyền sessionToken xuống.
  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,
  });
}

Lưu key vào biến môi trường chỉ có ở server:

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

Đừng thêm tiền tố NEXT_PUBLIC_ vào key — tiền tố đó cố ý đưa giá trị vào bundle trình duyệt.

Component <AddressAutocomplete>

Component là một <input> controlled, có debounce ~250ms trước khi gọi route proxy. Mỗi phản hồi /v1/suggest trả về mảng predictions, mỗi phần tử có 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);
  // Một sessionToken cho mỗi phiên autocomplete (gõ -> chọn).
  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, // chú ý: lon, KHÔNG phải lng
      district: result.district ?? null,
      city: result.city ?? null,
    });
 
    // Phiên đã kết thúc -> tạo token mới cho lần sau.
    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="Nhập địa chỉ…"
      />
      {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>
  );
}

Điều hướng bàn phím và a11y

Một ô autocomplete tốt phải dùng được hoàn toàn bằng bàn phím. Tách phần xử lý phím ra cho gọn:

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;
  }
}

Các thuộc tính role="combobox", aria-expanded, aria-activedescendantrole="option" ở trên giúp trình đọc màn hình hiểu được danh sách gợi ý — đừng bỏ qua chúng.

Resolve địa điểm đã chọn

Khi người dùng chọn một prediction, ta đổi placeId thành bản ghi đầy đủ qua route proxy thứ hai. Tạo 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");
 
  // Dùng đúng sessionToken của phiên suggest để billing gộp đúng.
  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,
  });
}

Phản hồi /v1/place/resolve trả về result gồm placeId, name, address, lat, lon, district, city, country. Vài lưu ý dễ vấp:

  • Toạ độ là lon, không phải lng — đặt sai tên trường là lỗi hay gặp nhất.
  • district (phường/xã hoặc quận/huyện) và city có thể null — luôn xử lý trường hợp thiếu.
  • Chỉ resolve sau khi người dùng chọn, đừng resolve mỗi lần gõ — vừa chậm vừa tốn quota.

Trả ra giá trị ổn định để lưu

Mục tiêu cuối không phải là một dropdown đẹp, mà là một bản ghi ổn định bạn lưu được vào đơn hàng: text hiển thị, placeId, và lat/lon. Khi đã có toạ độ, form checkout của bạn có thể tính phí ship theo khoảng cách, gán vùng giao hàng, hay xác định phường/xã — mà không cần người dùng gõ lại.

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 đã sẵn sàng để tính ship hoặc gán vùng */}
    </form>
  );
}

Bước tiếp theo

Bạn vừa có một component autocomplete địa chỉ Việt Nam an toàn (giấu key), gọn (debounce + một phiên billing) và dùng được bằng bàn phím. Từ lat/lon lấy được, hãy đi tiếp:

Muốn dùng GoGoDuk?

Miễn phí trọn đời — 100 request/ngày mỗi tài khoản, không cần thẻ tín dụng. Giới hạn cao hơn theo yêu cầu.

Đăng ký →