Build a Route Planner with Turn-by-Turn Directions in MapLibre GL JS
A complete route planner in one page: drag the A and B pins, and the panel shows the addresses, the travel time, distance and climb, and every turn. Hover a step to highlight that stretch of the route, click it to fly there, and switch between car, bike and walking routes at the top.
const API_KEY = "YOUR_API_KEY";
let routeType = "car";
const map = new maplibregl.Map({
container: "map",
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [11.405, 47.27],
zoom: 13,
attributionControl: { compact: false },
});
map.addControl(new maplibregl.NavigationControl(), "bottom-right");
function pin(letter, color) {
const element = document.createElement("div");
element.className = "pin";
element.style.background = color;
element.textContent = letter;
return element;
}
const waypoints = {
a: new maplibregl.Marker({ element: pin("A", "#2f9e44"), draggable: true }).setLngLat([11.393712, 47.259938]).addTo(map),
b: new maplibregl.Marker({ element: pin("B", "#d6336c"), draggable: true }).setLngLat([11.430896, 47.28187]).addTo(map),
};
// One arrow, turned to match the maneuver. The Routing API "sign" codes the turn.
const ARROW = '<svg viewBox="0 0 24 24"><path d="M12 3l6 7h-4v11h-4V10H6z"/></svg>';
const DOT = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="6"/></svg>';
const TURNS = { "-7": -25, "-3": -135, "-2": -90, "-1": -45, "0": 0, "1": 45, "2": 90, "3": 135, "7": 25, "-8": 180, "8": 180, "-98": 180 };
function maneuverIcon(sign) {
if (!(String(sign) in TURNS)) return DOT;
return ARROW.replace("<svg", `<svg style="transform: rotate(${TURNS[sign]}deg)"`);
}
function formatDistance(meters) {
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`;
}
function formatTime(milliseconds) {
const minutes = Math.round(milliseconds / 60000);
return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
}
async function updateAddress(key) {
const { lng, lat } = waypoints[key].getLngLat();
const url = new URL("https://geocoder.maptoolkit.net/reverse");
url.searchParams.set("lat", lat);
url.searchParams.set("lon", lng);
url.searchParams.set("api_key", API_KEY);
const result = await fetch(url).then((response) => response.json());
const address = result.address || {};
const street = [address.road, address.house_number].filter(Boolean).join(" ");
const place = address.city || address.town || address.village || "";
document.getElementById(`address-${key}`).textContent =
[street, place].filter(Boolean).join(", ") || (result.display_name || "").split(",")[0];
}
let latestRequest = 0;
let firstRoute = true;
async function updateRoute() {
const request = ++latestRequest;
document.getElementById("panel").classList.add("loading");
const url = new URL("https://routing.maptoolkit.net/route");
// The Routing API takes "latitude,longitude"; markers return longitude and latitude.
for (const marker of [waypoints.a, waypoints.b]) {
const { lng, lat } = marker.getLngLat();
url.searchParams.append("point", `${lat},${lng}`);
}
url.searchParams.set("routeType", routeType);
url.searchParams.set("points_encoded", "false");
url.searchParams.set("api_key", API_KEY);
const data = await fetch(url).then((response) => response.json());
// A slower answer to an earlier drag must not overwrite a newer route.
if (request !== latestRequest) return;
document.getElementById("panel").classList.remove("loading");
const steps = document.getElementById("steps");
if (!data.paths) {
document.getElementById("time").textContent = "No route found";
document.getElementById("details").textContent = "Move A or B closer to a road or path.";
steps.replaceChildren();
map.getSource("route").setData({ type: "FeatureCollection", features: [] });
return;
}
const path = data.paths[0];
const line = path.points.coordinates;
map.getSource("route").setData({ type: "Feature", properties: {}, geometry: path.points });
document.getElementById("time").textContent = formatTime(path.time);
document.getElementById("details").textContent =
`${formatDistance(path.distance)} · ${Math.round(path.ascend)} m up, ${Math.round(path.descend)} m down`;
steps.replaceChildren(...path.instructions.map((instruction) => {
const item = document.createElement("li");
item.innerHTML = `<span class="icon">${maneuverIcon(instruction.sign)}</span>` +
`<span class="text">${instruction.text}</span>` +
`<span class="distance">${instruction.distance > 0 ? formatDistance(instruction.distance) : ""}</span>`;
// "interval" indexes the part of the route geometry this instruction covers.
const [from, to] = instruction.interval;
const segment = { type: "LineString", coordinates: line.slice(from, to + 1) };
item.addEventListener("mouseenter", () => map.getSource("step").setData(segment));
item.addEventListener("mouseleave", () => map.getSource("step").setData({ type: "FeatureCollection", features: [] }));
// Instruction coordinates are [latitude, longitude].
const [lat, lng] = instruction.coordinate;
item.addEventListener("click", () => map.flyTo({ center: [lng, lat], zoom: 16 }));
return item;
}));
if (firstRoute) {
firstRoute = false;
// Keep the route clear of the panel: beside it on wide screens, below it on phones.
const panel = document.getElementById("panel").getBoundingClientRect();
const padding = panel.width < window.innerWidth / 2
? { top: 60, bottom: 60, left: panel.right + 40, right: 60 }
: { top: panel.bottom + 30, bottom: 40, left: 40, right: 40 };
const bounds = line.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(line[0], line[0]));
map.fitBounds(bounds, { padding, duration: 0 });
}
}
// "style.load" fires as soon as the style is parsed; "load" would also wait for every tile.
map.once("style.load", () => {
const empty = { type: "FeatureCollection", features: [] };
map.addSource("route", { type: "geojson", data: empty });
map.addSource("step", { type: "geojson", data: empty });
const round = { "line-join": "round", "line-cap": "round" };
map.addLayer({ id: "route-casing", type: "line", source: "route", layout: round, paint: { "line-color": "#ffffff", "line-width": 9 } });
map.addLayer({ id: "route", type: "line", source: "route", layout: round, paint: { "line-color": "#303f7e", "line-width": 5 } });
map.addLayer({ id: "step", type: "line", source: "step", layout: round, paint: { "line-color": "#f59f00", "line-width": 7 } });
updateRoute();
updateAddress("a");
updateAddress("b");
});
for (const key of ["a", "b"]) {
waypoints[key].on("dragend", () => {
updateRoute();
updateAddress(key);
});
}
document.querySelectorAll(".modes button").forEach((button) => {
button.addEventListener("click", () => {
routeType = button.dataset.mode;
document.querySelectorAll(".modes button").forEach((b) => b.classList.toggle("active", b === button));
updateRoute();
});
});<!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; bottom: 12px; z-index: 1; width: 320px;
display: flex; flex-direction: column; background: #fff; border-radius: 12px;
box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18); font: 14px/1.4 system-ui, sans-serif; color: #1f2430;
overflow: hidden;
}
.modes { display: flex; gap: 4px; margin: 14px 14px 10px; padding: 4px; background: #eef0f6; border-radius: 10px; }
.modes button {
flex: 1; padding: 7px 0; border: none; border-radius: 7px; background: none; cursor: pointer;
font: 600 13px system-ui, sans-serif; color: #4a5068;
}
.modes button.active { background: #fff; color: #303f7e; box-shadow: 0 1px 3px rgba(20, 30, 60, 0.15); }
.waypoints { margin: 0 14px; }
.waypoint { display: flex; align-items: center; gap: 10px; padding: 7px 0; }
.waypoint + .waypoint { border-top: 1px solid #eef0f6; }
.waypoint .address { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge {
flex: none; width: 22px; height: 22px; border-radius: 50%; color: #fff;
font: 700 12px/22px system-ui, sans-serif; text-align: center;
}
.badge.a { background: #2f9e44; }
.badge.b { background: #d6336c; }
.summary { margin: 10px 14px; padding: 12px 14px; border-radius: 10px; background: #303f7e; color: #fff; transition: opacity 0.2s; }
.summary .time { font: 700 24px/1.2 system-ui, sans-serif; }
.summary .details { margin-top: 2px; color: #c9cfe8; font-size: 13px; }
#panel.loading .summary { opacity: 0.55; }
#steps { flex: 1; margin: 0; padding: 0 6px 10px; overflow-y: auto; list-style: none; }
#steps li {
display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; cursor: pointer;
}
#steps li:hover { background: #f3f4f9; }
#steps .icon { flex: none; width: 28px; height: 28px; border-radius: 50%; background: #eef0f6; display: grid; place-items: center; }
#steps .icon svg { width: 16px; height: 16px; fill: #303f7e; }
#steps .text { flex: 1; }
#steps .distance { color: #7a8099; font-size: 12px; white-space: nowrap; }
.hint { padding: 8px 14px 12px; color: #7a8099; font-size: 12px; border-top: 1px solid #eef0f6; }
.pin {
width: 30px; height: 30px; border-radius: 50%; border: 3px solid #fff; box-sizing: border-box;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); color: #fff; cursor: grab;
font: 700 13px/24px system-ui, sans-serif; text-align: center;
}
@media (max-width: 600px) {
#panel { width: auto; right: 12px; bottom: auto; max-height: 45%; }
}
</style>
</head>
<body>
<div id="map"></div>
<div id="panel">
<div class="modes">
<button data-mode="car" class="active">Car</button>
<button data-mode="bike">Bike</button>
<button data-mode="foot">Walk</button>
</div>
<div class="waypoints">
<div class="waypoint"><span class="badge a">A</span><span class="address" id="address-a">Start</span></div>
<div class="waypoint"><span class="badge b">B</span><span class="address" id="address-b">Destination</span></div>
</div>
<div class="summary"><div class="time" id="time"> </div><div class="details" id="details"> </div></div>
<ol id="steps"></ol>
<div class="hint">Drag A or B to change the route. Hover a step to see it on the map.</div>
</div>
<script>
const API_KEY = "YOUR_API_KEY";
let routeType = "car";
const map = new maplibregl.Map({
container: "map",
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [11.405, 47.27],
zoom: 13,
attributionControl: { compact: false },
});
map.addControl(new maplibregl.NavigationControl(), "bottom-right");
function pin(letter, color) {
const element = document.createElement("div");
element.className = "pin";
element.style.background = color;
element.textContent = letter;
return element;
}
const waypoints = {
a: new maplibregl.Marker({ element: pin("A", "#2f9e44"), draggable: true }).setLngLat([11.393712, 47.259938]).addTo(map),
b: new maplibregl.Marker({ element: pin("B", "#d6336c"), draggable: true }).setLngLat([11.430896, 47.28187]).addTo(map),
};
// One arrow, turned to match the maneuver. The Routing API "sign" codes the turn.
const ARROW = '<svg viewBox="0 0 24 24"><path d="M12 3l6 7h-4v11h-4V10H6z"/></svg>';
const DOT = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="6"/></svg>';
const TURNS = { "-7": -25, "-3": -135, "-2": -90, "-1": -45, "0": 0, "1": 45, "2": 90, "3": 135, "7": 25, "-8": 180, "8": 180, "-98": 180 };
function maneuverIcon(sign) {
if (!(String(sign) in TURNS)) return DOT;
return ARROW.replace("<svg", `<svg style="transform: rotate(${TURNS[sign]}deg)"`);
}
function formatDistance(meters) {
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`;
}
function formatTime(milliseconds) {
const minutes = Math.round(milliseconds / 60000);
return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
}
async function updateAddress(key) {
const { lng, lat } = waypoints[key].getLngLat();
const url = new URL("https://geocoder.maptoolkit.net/reverse");
url.searchParams.set("lat", lat);
url.searchParams.set("lon", lng);
url.searchParams.set("api_key", API_KEY);
const result = await fetch(url).then((response) => response.json());
const address = result.address || {};
const street = [address.road, address.house_number].filter(Boolean).join(" ");
const place = address.city || address.town || address.village || "";
document.getElementById(`address-${key}`).textContent =
[street, place].filter(Boolean).join(", ") || (result.display_name || "").split(",")[0];
}
let latestRequest = 0;
let firstRoute = true;
async function updateRoute() {
const request = ++latestRequest;
document.getElementById("panel").classList.add("loading");
const url = new URL("https://routing.maptoolkit.net/route");
// The Routing API takes "latitude,longitude"; markers return longitude and latitude.
for (const marker of [waypoints.a, waypoints.b]) {
const { lng, lat } = marker.getLngLat();
url.searchParams.append("point", `${lat},${lng}`);
}
url.searchParams.set("routeType", routeType);
url.searchParams.set("points_encoded", "false");
url.searchParams.set("api_key", API_KEY);
const data = await fetch(url).then((response) => response.json());
// A slower answer to an earlier drag must not overwrite a newer route.
if (request !== latestRequest) return;
document.getElementById("panel").classList.remove("loading");
const steps = document.getElementById("steps");
if (!data.paths) {
document.getElementById("time").textContent = "No route found";
document.getElementById("details").textContent = "Move A or B closer to a road or path.";
steps.replaceChildren();
map.getSource("route").setData({ type: "FeatureCollection", features: [] });
return;
}
const path = data.paths[0];
const line = path.points.coordinates;
map.getSource("route").setData({ type: "Feature", properties: {}, geometry: path.points });
document.getElementById("time").textContent = formatTime(path.time);
document.getElementById("details").textContent =
`${formatDistance(path.distance)} · ${Math.round(path.ascend)} m up, ${Math.round(path.descend)} m down`;
steps.replaceChildren(...path.instructions.map((instruction) => {
const item = document.createElement("li");
item.innerHTML = `<span class="icon">${maneuverIcon(instruction.sign)}</span>` +
`<span class="text">${instruction.text}</span>` +
`<span class="distance">${instruction.distance > 0 ? formatDistance(instruction.distance) : ""}</span>`;
// "interval" indexes the part of the route geometry this instruction covers.
const [from, to] = instruction.interval;
const segment = { type: "LineString", coordinates: line.slice(from, to + 1) };
item.addEventListener("mouseenter", () => map.getSource("step").setData(segment));
item.addEventListener("mouseleave", () => map.getSource("step").setData({ type: "FeatureCollection", features: [] }));
// Instruction coordinates are [latitude, longitude].
const [lat, lng] = instruction.coordinate;
item.addEventListener("click", () => map.flyTo({ center: [lng, lat], zoom: 16 }));
return item;
}));
if (firstRoute) {
firstRoute = false;
// Keep the route clear of the panel: beside it on wide screens, below it on phones.
const panel = document.getElementById("panel").getBoundingClientRect();
const padding = panel.width < window.innerWidth / 2
? { top: 60, bottom: 60, left: panel.right + 40, right: 60 }
: { top: panel.bottom + 30, bottom: 40, left: 40, right: 40 };
const bounds = line.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(line[0], line[0]));
map.fitBounds(bounds, { padding, duration: 0 });
}
}
// "style.load" fires as soon as the style is parsed; "load" would also wait for every tile.
map.once("style.load", () => {
const empty = { type: "FeatureCollection", features: [] };
map.addSource("route", { type: "geojson", data: empty });
map.addSource("step", { type: "geojson", data: empty });
const round = { "line-join": "round", "line-cap": "round" };
map.addLayer({ id: "route-casing", type: "line", source: "route", layout: round, paint: { "line-color": "#ffffff", "line-width": 9 } });
map.addLayer({ id: "route", type: "line", source: "route", layout: round, paint: { "line-color": "#303f7e", "line-width": 5 } });
map.addLayer({ id: "step", type: "line", source: "step", layout: round, paint: { "line-color": "#f59f00", "line-width": 7 } });
updateRoute();
updateAddress("a");
updateAddress("b");
});
for (const key of ["a", "b"]) {
waypoints[key].on("dragend", () => {
updateRoute();
updateAddress(key);
});
}
document.querySelectorAll(".modes button").forEach((button) => {
button.addEventListener("click", () => {
routeType = button.dataset.mode;
document.querySelectorAll(".modes button").forEach((b) => b.classList.toggle("active", b === button));
updateRoute();
});
});
</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 A and B pins are MapLibre Marker objects with a custom element and draggable: true. The
route is a GeoJSON source drawn twice, a wide white line under a narrower blue one, so it stays
readable over any part of the map. A third source holds the highlighted step, and every new route
or hover replaces a source’s data with setData().
Two APIs, one request each per drag. Dropping a marker sends one Routing API request for the
route and one Geocoding API request for the address of the marker that moved. Both run on
dragend, when the marker is released, not on drag, which fires dozens of times per second and
would spend your quota on routes nobody sees.
Only the newest route is drawn. Requests can finish out of order when someone drags twice in
quick succession. latestRequest numbers each request, and an answer that is no longer the newest
is dropped, so an older route never replaces a newer one.
Each instruction knows its part of the line. interval holds the first and last index of the
route coordinates the instruction covers, so slicing the geometry with it gives the stretch to
highlight when the pointer is over a step. sign says which way to turn, from -3 for a sharp
left to 3 for a sharp right, with 0 for straight on; the step icon is one arrow rotated to
match. Other codes, such as the finish and roundabouts, get a dot.
points_encoded=false returns the route geometry as a GeoJSON LineString, so it needs no
polyline decoder. time is in milliseconds and distance in meters, and ascend and descend
give the climb in meters.
Three coordinate orders meet here. The point parameter takes latitude,longitude, each
instruction’s coordinate is [latitude, longitude], and the route geometry is GeoJSON, so it is
[longitude, latitude]. The reverse geocoder takes lat and lon as separate parameters.
routeType also accepts hike, roads and transit, which the
Routing API reference describes. An unknown value does not return an
error: the API answers with a bike route, so a typo shows up as a wrong route, not a failed
request.
The first route is fitted into the part of the map the panel does not cover: to the right of it on wide screens, below it on phones, where the panel spans the top.
The route sources and layers are added on style.load, which fires as soon as the style is
ready for new layers. The more common load event also waits until every initial tile has
arrived, which can hold the first route back by several seconds.
Next steps
The same request takes more than two point parameters, in order, so a via point is a third
draggable marker placed between A and B.
For hiking and cycling routes the climb matters more than the minutes. The route with elevation profile example turns the same geometry into a climb chart with the Elevation API.