Skip to content

Snap a GPS Track to the Road Network in Leaflet

A phone recording a walk produces a line that drifts across rivers, cuts corners and wanders through buildings. The Map Matching endpoint of the Maptoolkit Routing API snaps that track onto the streets and paths it was recorded on and returns a clean route. This example loads a real 5 km recording from Innsbruck, matches it, and draws both lines in Leaflet so you can see what changed. No GPX plugin is needed.

const API_KEY = "YOUR_API_KEY";

    // The same file twice: the browser reads it to draw the recorded line, and the
    // Map Matching API downloads it itself, so that URL must be absolute and public.
    const GPX_PATH = "innsbruck-walk.gpx";
    const GPX_URL = "https://docs.maptoolkit.com/demos/innsbruck-walk.gpx";

    const map = L.map("map", { zoomControl: false }).setView([47.2716, 11.412], 14);
    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 status = L.control({ position: "topleft" });
    status.onAdd = () => L.DomUtil.create("div", "status");
    status.addTo(map);
    status.getContainer().textContent = "Matching track...";

    (async () => {
      // The recorded track, parsed in the browser only to draw it. GPX stores lat and lon as attributes.
      const xml = new DOMParser().parseFromString(await (await fetch(GPX_PATH)).text(), "application/xml");
      const recorded = [...xml.getElementsByTagName("trkpt")].map((point) =>
        [Number(point.getAttribute("lat")), Number(point.getAttribute("lon"))]);
      const recordedLine = L.polyline(recorded, { color: "#e8590c", weight: 3, dashArray: "6 6" }).addTo(map);

      // POST, because a long track does not fit in a query string. The api_key stays in the URL.
      const response = await fetch(`https://routing.maptoolkit.net/match?api_key=${API_KEY}`, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({ gpx: GPX_URL, routeType: "foot", points_encoded: "false" }),
      });
      if (!response.ok) {
        status.getContainer().textContent = `Matching failed: ${response.status}`;
        return;
      }
      const path = (await response.json()).paths[0];

      // With points_encoded=false the matched line is GeoJSON, like a route.
      const matchedLine = L.geoJSON(path.points, { style: { color: "#1c7ed6", weight: 4, opacity: 1 } }).addTo(map);
      map.fitBounds(matchedLine.getBounds(), { paddingTopLeft: [40, 60], paddingBottomRight: [40, 40] });

      status.getContainer().textContent = `${recorded.length} recorded points, ${(path.distance / 1000).toFixed(2)} km matched`;
      L.control.layers(null, {
        '<span class="swatch dashed" style="color:#e8590c"></span>Recorded track': recordedLine,
        '<span class="swatch" style="color:#1c7ed6"></span>Matched route': matchedLine,
      }, { collapsed: false, position: "topleft" }).addTo(map);
    })();
<!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%; }
    .status {
      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;
    }
    .leaflet-control-layers { font: 13px/1.6 system-ui, sans-serif; }
    .swatch { display: inline-block; width: 18px; margin: 0 4px 3px 2px; border-top: 4px solid; vertical-align: middle; }
    .swatch.dashed { border-top: 3px dashed; }
  </style>
</head>
<body>
  <div id="map"></div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    // The same file twice: the browser reads it to draw the recorded line, and the
    // Map Matching API downloads it itself, so that URL must be absolute and public.
    const GPX_PATH = "innsbruck-walk.gpx";
    const GPX_URL = "https://docs.maptoolkit.com/demos/innsbruck-walk.gpx";

    const map = L.map("map", { zoomControl: false }).setView([47.2716, 11.412], 14);
    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 status = L.control({ position: "topleft" });
    status.onAdd = () => L.DomUtil.create("div", "status");
    status.addTo(map);
    status.getContainer().textContent = "Matching track...";

    (async () => {
      // The recorded track, parsed in the browser only to draw it. GPX stores lat and lon as attributes.
      const xml = new DOMParser().parseFromString(await (await fetch(GPX_PATH)).text(), "application/xml");
      const recorded = [...xml.getElementsByTagName("trkpt")].map((point) =>
        [Number(point.getAttribute("lat")), Number(point.getAttribute("lon"))]);
      const recordedLine = L.polyline(recorded, { color: "#e8590c", weight: 3, dashArray: "6 6" }).addTo(map);

      // POST, because a long track does not fit in a query string. The api_key stays in the URL.
      const response = await fetch(`https://routing.maptoolkit.net/match?api_key=${API_KEY}`, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({ gpx: GPX_URL, routeType: "foot", points_encoded: "false" }),
      });
      if (!response.ok) {
        status.getContainer().textContent = `Matching failed: ${response.status}`;
        return;
      }
      const path = (await response.json()).paths[0];

      // With points_encoded=false the matched line is GeoJSON, like a route.
      const matchedLine = L.geoJSON(path.points, { style: { color: "#1c7ed6", weight: 4, opacity: 1 } }).addTo(map);
      map.fitBounds(matchedLine.getBounds(), { paddingTopLeft: [40, 60], paddingBottomRight: [40, 40] });

      status.getContainer().textContent = `${recorded.length} recorded points, ${(path.distance / 1000).toFixed(2)} km matched`;
      L.control.layers(null, {
        '<span class="swatch dashed" style="color:#e8590c"></span>Recorded track': recordedLine,
        '<span class="swatch" style="color:#1c7ed6"></span>Matched route': matchedLine,
      }, { collapsed: false, position: "topleft" }).addTo(map);
    })();
  </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 with Maptoolkit raster tiles that loads a GPX recording, sends its URL to the Maptoolkit Map Matching API as GeoJSON, and draws the recorded track dashed and the matched result as a solid line, with a layer control to toggle each.

How it works

The response is a route. The Map Matching endpoint answers in the same format as the Routing API: paths[0] with points, distance, bbox and instructions. With points_encoded=false, points is a GeoJSON LineString, which L.geoJSON draws directly.

No GPX plugin. A GPX file is XML with one trkpt element per recorded point, carrying lat and lon attributes. DOMParser reads it, and the points go into an L.polyline in Leaflet’s [lat, lng] order. For waypoints, elevation and several tracks, a plugin such as leaflet-gpx does more, but for drawing one line this is enough.

The service downloads the GPX itself. The gpx parameter is a URL, and the Map Matching API fetches the file from there, so it has to be reachable from the public internet. A file on localhost, on a private network or behind a login fails with a 404. That is why the example has two URLs for one file: the browser reads it with a relative path, which needs no CORS header, and the service gets the absolute one. To match a track the browser already holds, send it as repeated point parameters (lat,lng) instead of gpx.

The api_key goes in the URL, even on a POST. Sent as a form field, it is not read and the request fails with 403 Access denied!, which looks like a problem with the key rather than where it was put.

Keep the recorded line. Drawing the recording dashed under the matched line is worth keeping in real tools: it is the only way to see whether the matcher chose the right path, and next to a parallel cycleway or road it sometimes doesn’t. L.control.layers with only overlays gives each line a checkbox, and its labels can hold HTML, here a line swatch.

instructions=false makes the endpoint return 400 No map-matching possible for your track., which blames the track rather than the parameter; leave it out. Very long tracks are rejected with a 400 rather than shortened, so split a long recording and match the parts.

Next steps

Matching is for recordings. A route from the Routing API already follows the network, as in the route planner.

For elevation and surface along a recorded track, the Route Enhancement API matches the track itself and returns the data in one request, as in Style a Route by Surface Type.

The same example in Maptoolkit Maps JS is Snap a GPS Track to the Road Network.