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.
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.