All posts

Embed a MapLibre Map + Vietnam Address Search in Plain JavaScript

Embed a MapLibre map and a Vietnam address autocomplete with plain HTML/JavaScript — no framework, using GoGoDuk's /v1/suggest, and drop a pin from the coordinates.

Not every project needs React. A landing page, a contact page, or a booking form often just needs a map and an address box — and plain HTML plus JavaScript is enough, without dragging in a whole framework.

This post does exactly that: embed a MapLibre GL JS map and a Vietnam address autocomplete using two public GoGoDuk endpoints — /v1/suggest for predictions and /v1/place/resolve for coordinates — then drop a pin on the map. The whole thing fits in one HTML file plus a tiny backend proxy.

Why plain JavaScript

If you already run a React/Next.js app, use the React autocomplete component — it's cleaner for complex forms. But for a static page, adding React just to get one input is overkill: bigger bundle, heavier build, harder for a newcomer to edit.

Plain JavaScript fits when:

  • The page is small and low-interaction — a landing page, a "Contact" or "Stores" page.
  • You're embedding a map into an existing site (WordPress, static HTML) without changing the whole stack.
  • You want to understand each moving part before wrapping it in a component.

Embed a basic MapLibre map

MapLibre GL JS is an open-source vector map renderer (a fork of Mapbox GL JS, free). Load it from a CDN and create a map pointing at your style:

<!DOCTYPE html>
<html lang="vi">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" rel="stylesheet" />
  <style>
    body { margin: 0; font-family: system-ui, sans-serif; }
    #map { position: absolute; inset: 0; }
    #search { position: absolute; top: 12px; left: 12px; z-index: 1; width: 320px; }
    #search input { width: 100%; padding: 10px 12px; border: 1px solid #d6dce2; border-radius: 8px; }
    #results { background: #fff; border-radius: 8px; margin-top: 4px; box-shadow: 0 6px 18px rgba(0,0,0,.12); }
    .item { padding: 8px 12px; cursor: pointer; }
    .item:hover { background: #eef4fa; }
    .item small { color: #7d8c9a; display: block; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="search">
    <input id="q" type="text" placeholder="Search an address in Vietnam..." autocomplete="off" />
    <div id="results"></div>
  </div>
 
  <script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
  <script>
    const map = new maplibregl.Map({
      container: "map",
      style: "https://demotiles.maplibre.org/style.json", // replace with your style
      center: [106.7009, 10.7769], // Ho Chi Minh City: [lon, lat]
      zoom: 12,
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");
  </script>
</body>
</html>

Note MapLibre takes coordinates as [lon, lat] (longitude first), the opposite of the usual "lat, lon" habit — this is the single most common beginner mistake.

Why you need a backend proxy

The big temptation is to call api.gogoduk.com straight from browser JavaScript. Don't. That puts your X-API-Key in plain sight in the Network tab and the page source — anyone can copy it and burn your quota.

The right way: the browser calls a backend route of your own, and that route attaches X-API-Key before forwarding to GoGoDuk. The key never leaves the server. Here's a plain Node example (no framework), but the principle is identical in PHP, Python, or any backend:

// server.js — tiny proxy that hides the API key (Node, no framework)
const http = require("http");
 
const API = "https://api.gogoduk.com";
const KEY = process.env.GOGODUK_API_KEY; // set via env var, do NOT hardcode
 
async function forward(path, res) {
  const r = await fetch(`${API}${path}`, { headers: { "X-API-Key": KEY } });
  const body = await r.text();
  res.writeHead(r.status, { "Content-Type": "application/json" });
  res.end(body);
}
 
http.createServer((req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname === "/api/suggest") {
    const input = url.searchParams.get("input") || "";
    return forward(`/v1/suggest?input=${encodeURIComponent(input)}&lang=vi&country=VN`, res);
  }
  if (url.pathname === "/api/resolve") {
    const id = url.searchParams.get("id") || "";
    return forward(`/v1/place/resolve?id=${encodeURIComponent(id)}`, res);
  }
  res.writeHead(404); res.end();
}).listen(3000);

The browser only ever sees /api/suggest and /api/resolve — never the key.

Add the search box + call /v1/suggest (with debounce)

/v1/suggest takes input (minimum 2 characters) and returns a list of predictions, each with placeId, mainText (the primary line) and secondaryText (the admin hierarchy). Don't fire a request per keystroke — debounce it so fast typing collapses into one request, lighter and cheaper on quota:

const q = document.getElementById("q");
const results = document.getElementById("results");
let timer = null;
 
q.addEventListener("input", () => {
  clearTimeout(timer);
  const text = q.value.trim();
  if (text.length < 2) { results.innerHTML = ""; return; }
  // Collapse keystrokes within 300ms into a single request.
  timer = setTimeout(() => fetchSuggestions(text), 300);
});
 
async function fetchSuggestions(text) {
  const res = await fetch(`/api/suggest?input=${encodeURIComponent(text)}`);
  const data = await res.json();
  renderResults(data.predictions || []);
}
 
function renderResults(predictions) {
  results.innerHTML = predictions.map((p) => `
    <div class="item" data-id="${p.placeId}">
      ${p.mainText}
      <small>${p.secondaryText || ""}</small>
    </div>
  `).join("");
}

Select a result → resolve coordinates → drop a pin

Autocomplete only returns a placeId (a stable identifier), not coordinates. When the user clicks a suggestion, call /v1/place/resolve to get result.lat and result.lon, then place a marker and flyTo it:

let marker = null;
 
results.addEventListener("click", async (e) => {
  const item = e.target.closest(".item");
  if (!item) return;
 
  q.value = item.textContent.trim();
  results.innerHTML = "";
 
  const res = await fetch(`/api/resolve?id=${encodeURIComponent(item.dataset.id)}`);
  const { result } = await res.json();
  if (!result) return;
 
  const lngLat = [result.lon, result.lat]; // note: lon first, and the field is `lon` (not `lng`)
 
  if (marker) marker.remove();
  marker = new maplibregl.Marker()
    .setLngLat(lngLat)
    .setPopup(new maplibregl.Popup().setText(result.address || result.name))
    .addTo(map);
 
  map.flyTo({ center: lngLat, zoom: 16 });
});

Try typing "201 Trần Não" and picking the first suggestion — the map flies to 10.7866, 106.7298 and pins exactly at that address. The whole flow — type → /v1/suggest → select → /v1/place/resolve → marker — fits in one HTML file and a ten-line proxy.

A few production notes

  • Never put X-API-Key in the client. Always go through a backend proxy as above.
  • Debounce the input (200–400ms) so you don't spam requests or flicker results.
  • Coordinate order: MapLibre and GoGoDuk both use [lon, lat]; the resolve field is named lon (not lng). Get the order wrong and your pin lands in the sea.
  • Map style: demotiles.maplibre.org is only for testing. In production use a stable style/tile source (self-hosted or a tile provider) so the Vietnam map is crisp and detailed enough.

When to move to a framework

Plain JavaScript is fine for one search box and a map. But once the form gets complex — many fields, validation, keyboard navigation in the dropdown, binding to your checkout state — a structured component is easier to maintain. At that point move to the React/Next.js autocomplete component, or read the Vietnam Address Autocomplete API overview for the UX design behind it. Want a full nearest-branch finder? There's a ready guide on building a Store Locator with MapLibre.

/v1/suggest and /v1/place/resolve are in GoGoDuk's free tier: 100 requests/day per account, no credit card — enough for prototypes and many real small sites. Create an account, grab an API key, and embed your first map this afternoon. Questions or want to show off what you built? Join our Telegram support group.

Want to use GoGoDuk?

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

Sign up →