Skip to content

Get an Address from a Clicked Point in Leaflet

Reverse geocoding turns a coordinate into an address. A click-anywhere map, a drag-the-pin confirmation step and a “use my location” button all need it, because none of them start from something the user typed. This example listens for clicks on a Leaflet map, sends the coordinate to the reverse endpoint of the Maptoolkit Geocoding API and shows the address in a popup at that point. No plugin is needed: it is one fetch.

const API_KEY = "YOUR_API_KEY";

    const map = L.map("map", { zoomControl: false }).setView([48.2082, 16.3722], 16);
    L.control.zoom({ position: "topright" }).addTo(map);

    L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
      maxZoom: 18,
      attribution:
        "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
        "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
    }).addTo(map);

    const hint = L.control({ position: "topleft" });
    hint.onAdd = () => L.DomUtil.create("div", "hint");
    hint.addTo(map);
    hint.getContainer().textContent = "Click anywhere on the map";

    const marker = L.marker([0, 0]);
    let latest = 0;

    // Two lines of plain text, so an address can never inject markup into the page.
    function showAddress(title, detail) {
      const div = L.DomUtil.create("div", "address");
      L.DomUtil.create("strong", "", div).textContent = title;
      if (detail) L.DomUtil.create("span", "", div).textContent = detail;
      marker.setPopupContent(div);
    }

    map.on("click", async (event) => {
      const { lat, lng } = event.latlng;
      const request = ++latest;

      marker.setLatLng(event.latlng).addTo(map).bindPopup("", { maxWidth: 260 }).openPopup();
      showAddress("Looking up...");

      const url = new URL("https://geocoder.maptoolkit.net/reverse");
      url.searchParams.set("lat", lat);
      // The reverse endpoint takes lon, not lng.
      url.searchParams.set("lon", lng);
      url.searchParams.set("language", "en");
      url.searchParams.set("api_key", API_KEY);

      try {
        const result = await (await fetch(url)).json();
        // A later click has already sent its own request, so this answer is stale.
        if (request !== latest) return;

        const address = result.address || {};
        const street = [address.road, address.house_number].filter(Boolean).join(" ");
        const place = [address.postcode, address.city || address.town || address.village].filter(Boolean).join(" ");
        // Not every point has a street address: fall back through the fields that are present.
        const title = street || result.name || address.suburb || address.neighbourhood ||
          (result.display_name || "").split(",")[0] || "This location";

        showAddress(title, [place, address.country].filter(Boolean).join(", "));
      } catch {
        if (request === latest) showAddress("No address found here.");
      }
    });
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
  <script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    .hint {
      padding: 8px 12px; background: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
      font: 13px/1.4 system-ui, sans-serif; color: #1f2430;
    }
    .address { font: 13px/1.5 system-ui, sans-serif; color: #1f2430; }
    .address strong { display: block; font-size: 14px; }
    .address span { color: #6b7185; }
  </style>
</head>
<body>
  <div id="map"></div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    const map = L.map("map", { zoomControl: false }).setView([48.2082, 16.3722], 16);
    L.control.zoom({ position: "topright" }).addTo(map);

    L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
      maxZoom: 18,
      attribution:
        "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
        "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
    }).addTo(map);

    const hint = L.control({ position: "topleft" });
    hint.onAdd = () => L.DomUtil.create("div", "hint");
    hint.addTo(map);
    hint.getContainer().textContent = "Click anywhere on the map";

    const marker = L.marker([0, 0]);
    let latest = 0;

    // Two lines of plain text, so an address can never inject markup into the page.
    function showAddress(title, detail) {
      const div = L.DomUtil.create("div", "address");
      L.DomUtil.create("strong", "", div).textContent = title;
      if (detail) L.DomUtil.create("span", "", div).textContent = detail;
      marker.setPopupContent(div);
    }

    map.on("click", async (event) => {
      const { lat, lng } = event.latlng;
      const request = ++latest;

      marker.setLatLng(event.latlng).addTo(map).bindPopup("", { maxWidth: 260 }).openPopup();
      showAddress("Looking up...");

      const url = new URL("https://geocoder.maptoolkit.net/reverse");
      url.searchParams.set("lat", lat);
      // The reverse endpoint takes lon, not lng.
      url.searchParams.set("lon", lng);
      url.searchParams.set("language", "en");
      url.searchParams.set("api_key", API_KEY);

      try {
        const result = await (await fetch(url)).json();
        // A later click has already sent its own request, so this answer is stale.
        if (request !== latest) return;

        const address = result.address || {};
        const street = [address.road, address.house_number].filter(Boolean).join(" ");
        const place = [address.postcode, address.city || address.town || address.village].filter(Boolean).join(" ");
        // Not every point has a street address: fall back through the fields that are present.
        const title = street || result.name || address.suburb || address.neighbourhood ||
          (result.display_name || "").split(",")[0] || "This location";

        showAddress(title, [place, address.country].filter(Boolean).join(", "));
      } catch {
        if (request === latest) showAddress("No address found here.");
      }
    });
  </script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create a Leaflet map of Vienna with Maptoolkit raster tiles. When the user clicks the map, call the Maptoolkit Geocoding API reverse endpoint for that coordinate and show the street address in a popup, with a marker at the clicked point.

How it works

Leaflet’s click event carries latlng, the position under the cursor. The handler moves a marker there, opens its popup and asks the Geocoding API reverse endpoint what is at that coordinate.

The parameter is lon, not lng. The reverse endpoint takes lat and lon. A lng parameter is not rejected, it is ignored, and the answer then has nothing to do with the click.

Set language. It defaults to de, so an English page gets “Wien” and “Österreich” unless it asks for en.

The nearest feature is not always an address. A click on a square, a park or a shop returns that feature, sometimes with no road at all. The popup reads address.road and address.house_number first and then falls back through name, suburb and the first part of display_name, because any single field is empty somewhere.

Ignore stale answers. Clicks come faster than the network answers, and responses do not arrive in order. latest counts the requests, and a response only writes to the popup if no later click has been made in the meantime.

Text, not HTML. Leaflet popups accept HTML strings. The content here is built with L.DomUtil.create() and textContent instead, so a feature name from the map data is shown as text and never parsed as markup.

boundingbox in the response is [minLat, maxLat, minLng, maxLng]. To frame the map on a result with fitBounds(), reorder it to [[minLat, minLng], [maxLat, maxLng]].

Next steps

To type an address instead of clicking it, add the geocoder search control. From a chosen point, the route planner gives directions to it, and an isochrone shows what is reachable around it.

The same example in Maptoolkit Maps JS is Get an Address from a Clicked Point.