Skip to content

Snap a GPS Track to the Road Network in MapLibre GL JS

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 MapLibre GL JS so you can see what changed.

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 = new maplibregl.Map({
      container: "map",
      style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
      center: [11.412, 47.2716],
      zoom: 13.5,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    function addLine(id, geometry, paint) {
      map.addSource(id, { type: "geojson", data: geometry });
      map.addLayer({ id, type: "line", source: id, layout: { "line-join": "round", "line-cap": "round" }, paint });
    }

    map.once("style.load", async () => {
      const panel = document.getElementById("panel");

      // The recorded track, parsed in the browser only to draw it.
      const xml = new DOMParser().parseFromString(await (await fetch(GPX_PATH)).text(), "application/xml");
      const recorded = [...xml.getElementsByTagName("trkpt")].map((point) =>
        [Number(point.getAttribute("lon")), Number(point.getAttribute("lat"))]);
      addLine("recorded", { type: "LineString", coordinates: recorded },
        { "line-color": "#e8590c", "line-width": 3, "line-dasharray": [2, 1.5] });

      // 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) {
        panel.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.
      addLine("matched", path.points, { "line-color": "#1c7ed6", "line-width": 4 });
      const [minLng, minLat, maxLng, maxLat] = path.bbox;
      map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: { top: 40, bottom: 40, left: 220, right: 40 } });

      panel.innerHTML = `
        <label><input type="checkbox" data-layer="recorded" checked><span class="swatch dashed" style="color:#e8590c"></span>Recorded track</label>
        <label><input type="checkbox" data-layer="matched" checked><span class="swatch" style="color:#1c7ed6"></span>Matched route</label>
        <div class="stat">${recorded.length} recorded points, ${(path.distance / 1000).toFixed(2)} km matched</div>`;
      panel.addEventListener("change", (event) =>
        map.setLayoutProperty(event.target.dataset.layer, "visibility", event.target.checked ? "visible" : "none"));
    });
<!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/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
  <script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    #panel {
      position: absolute; top: 12px; left: 12px; z-index: 1; padding: 10px 12px;
      background: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
      font: 13px/1.7 system-ui, sans-serif; color: #1f2430;
    }
    #panel label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
    #panel .swatch { width: 18px; height: 0; border-top: 4px solid; }
    #panel .swatch.dashed { border-top-style: dashed; border-top-width: 3px; }
    #panel .stat { margin-top: 4px; color: #6b7185; font-size: 12px; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="panel">Matching track...</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 = new maplibregl.Map({
      container: "map",
      style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
      center: [11.412, 47.2716],
      zoom: 13.5,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    function addLine(id, geometry, paint) {
      map.addSource(id, { type: "geojson", data: geometry });
      map.addLayer({ id, type: "line", source: id, layout: { "line-join": "round", "line-cap": "round" }, paint });
    }

    map.once("style.load", async () => {
      const panel = document.getElementById("panel");

      // The recorded track, parsed in the browser only to draw it.
      const xml = new DOMParser().parseFromString(await (await fetch(GPX_PATH)).text(), "application/xml");
      const recorded = [...xml.getElementsByTagName("trkpt")].map((point) =>
        [Number(point.getAttribute("lon")), Number(point.getAttribute("lat"))]);
      addLine("recorded", { type: "LineString", coordinates: recorded },
        { "line-color": "#e8590c", "line-width": 3, "line-dasharray": [2, 1.5] });

      // 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) {
        panel.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.
      addLine("matched", path.points, { "line-color": "#1c7ed6", "line-width": 4 });
      const [minLng, minLat, maxLng, maxLat] = path.bbox;
      map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: { top: 40, bottom: 40, left: 220, right: 40 } });

      panel.innerHTML = `
        <label><input type="checkbox" data-layer="recorded" checked><span class="swatch dashed" style="color:#e8590c"></span>Recorded track</label>
        <label><input type="checkbox" data-layer="matched" checked><span class="swatch" style="color:#1c7ed6"></span>Matched route</label>
        <div class="stat">${recorded.length} recorded points, ${(path.distance / 1000).toFixed(2)} km matched</div>`;
      panel.addEventListener("change", (event) =>
        map.setLayoutProperty(event.target.dataset.layer, "visibility", event.target.checked ? "visible" : "none"));
    });
  </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 MapLibre GL JS map 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 checkboxes 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, so it goes straight into a MapLibre source, the same way a calculated route does.

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. The checkboxes toggle each layer’s visibility.

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.