All posts

Build a Store Locator for Vietnam with MapLibre and a Geocoding API

A practical Next.js tutorial for building a Vietnam store locator: address search, place resolution, nearest-branch ranking, and MapLibre markers without exposing your API key.

A store locator looks simple: show branches on a map and help users find the nearest one. In production, however, the feature has to connect three different problems:

  1. Convert a Vietnamese address into reliable coordinates.
  2. Rank branches by distance from the selected location.
  3. Render the result without exposing a private API key in browser code.

This guide builds that flow with Next.js, MapLibre GL JS, and the GoGoDuk /v1/suggest and /v1/place/resolve endpoints.

Architecture

Keep map rendering, address intelligence, and branch data separate:

Browser
  -> Next.js /api/locations/suggest
  -> GoGoDuk /v1/suggest
  -> user selects a placeId
  -> Next.js /api/locations/resolve
  -> GoGoDuk /v1/place/resolve
  -> rank branches
  -> render markers with MapLibre

The browser calls your own backend routes. The backend adds X-API-Key, so the key never appears in client JavaScript or the network requests sent directly to a third-party domain.

Model your branches

Geocode branch addresses when they are created or updated, not on every customer search. Store coordinates as numbers and keep the original address for display:

export type Branch = {
  id: string;
  name: string;
  address: string;
  lat: number;
  lon: number;
  phone?: string;
  openingHours?: string;
};
 
export const branches: Branch[] = [
  {
    id: "hcm-ben-thanh",
    name: "Ben Thanh branch",
    address: "26-28 Le Thanh Ton, District 1, Ho Chi Minh City",
    lat: 10.7781,
    lon: 106.6994,
    openingHours: "08:00-21:00",
  },
  {
    id: "hcm-thao-dien",
    name: "Thao Dien branch",
    address: "Xuan Thuy, Thao Dien, Ho Chi Minh City",
    lat: 10.8032,
    lon: 106.7341,
    openingHours: "08:00-21:00",
  },
];

For a large network, store this data in PostgreSQL/PostGIS and query nearby branches with a spatial index. For tens or hundreds of branches, ranking a small in-memory result set is often enough.

Proxy address suggestions through Next.js

Create app/api/locations/suggest/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 input = new URL(req.url).searchParams.get("input")?.trim() ?? "";
  if (input.length < 2) {
    return NextResponse.json({ predictions: [] });
  }
 
  const upstream = new URL("/v1/suggest", API_URL);
  upstream.searchParams.set("input", input);
  upstream.searchParams.set("country", "VN");
  upstream.searchParams.set("lang", "vi");
 
  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 a value to browser bundles.

Resolve the selected place

Autocomplete results contain a stable placeId. After the user chooses an item, resolve that ID into coordinates through another server route:

import { NextResponse } from "next/server";
 
const API_URL = process.env.GOGODUK_API_URL ?? "https://api.gogoduk.com";
 
export async function GET(req: Request) {
  const id = new URL(req.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");
 
  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,
  });
}

Only resolve a place after selection. Calling place resolution for every keystroke adds latency and consumes quota without improving the dropdown.

Rank the nearest branches

For straight-line proximity, use the Haversine formula:

type Point = { lat: number; lon: number };
 
function distanceKm(a: Point, b: Point) {
  const radiusKm = 6371;
  const toRad = (value: number) => (value * Math.PI) / 180;
  const dLat = toRad(b.lat - a.lat);
  const dLon = toRad(b.lon - a.lon);
  const lat1 = toRad(a.lat);
  const lat2 = toRad(b.lat);
 
  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
 
  return 2 * radiusKm * Math.asin(Math.sqrt(h));
}
 
export function nearestBranches(origin: Point, branches: Branch[]) {
  return branches
    .map((branch) => ({
      ...branch,
      distanceKm: distanceKm(origin, branch),
    }))
    .sort((a, b) => a.distanceKm - b.distanceKm);
}

Haversine is useful for shortlist ranking, but it is not driving distance. A river, bridge, one-way street, or mountain can make the nearest branch by straight line slower to reach. Label the value as "approximately X km away", and use a routing or distance-matrix service when travel time affects the business decision.

Render the result with MapLibre

Install MapLibre:

npm install maplibre-gl

The map receives only coordinates and display data:

import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
 
const map = new maplibregl.Map({
  container: "store-map",
  style: process.env.NEXT_PUBLIC_MAP_STYLE_URL!,
  center: [106.7009, 10.7769],
  zoom: 12,
});
 
for (const branch of rankedBranches) {
  const popup = document.createElement("div");
  popup.textContent =
    `${branch.name} - ${branch.address} - ` +
    `${branch.distanceKm.toFixed(1)} km away`;
 
  new maplibregl.Marker()
    .setLngLat([branch.lon, branch.lat])
    .setPopup(new maplibregl.Popup().setDOMContent(popup))
    .addTo(map);
}

MapLibre is the renderer; it does not provide address search by itself. Your style can come from your own tile server or another compatible provider while GoGoDuk handles Vietnamese address search and place resolution. Unlike the GoGoDuk API key, a browser-facing map style URL is expected to be public.

Production checklist

  • Debounce autocomplete by 200-300 ms.
  • Cancel stale requests when the input changes quickly.
  • Require users to select a suggestion instead of trusting free-form text.
  • Keep the GoGoDuk key in a server-only environment variable.
  • Cache stable branch data and pre-geocode branch addresses.
  • Show district/city context for branches with similar names.
  • Treat Haversine distance as an estimate, not route duration.
  • Return a list view alongside the map for accessibility and mobile users.

Where this differs from delivery zones

A store locator ranks discrete branch points. A delivery-zone system answers whether a customer lies inside a service polygon and may apply a fee for that area. If your next step is shipping coverage, read How to build delivery zones in Vietnam.

For more detail on autocomplete UX and address validation, see Vietnam Address Autocomplete API.

Start building

Create a free API key, test a real customer address through /v1/suggest, resolve the selected placeId, and rank it against a small branch dataset. That is enough to ship the first useful version of a Vietnam store locator without coupling address search to your map renderer.

Want to use GoGoDuk?

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

Sign up →