Skip to content

Build a Route Planner with Turn-by-Turn Directions in Leaflet

A complete route planner between two points, built with plain Leaflet and fetch and no routing plugin. 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 = L.map("map", { zoomControl: false }).setView([47.27, 11.405], 14);
    L.control.zoom({ position: "bottomright" }).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);

    // A white casing under the route line keeps it readable on any background.
    const casing = L.geoJSON(null, { style: { color: "#ffffff", weight: 9, opacity: 1 } }).addTo(map);
    const route = L.geoJSON(null, { style: { color: "#303f7e", weight: 5, opacity: 1 } }).addTo(map);
    const step = L.geoJSON(null, { style: { color: "#f59f00", weight: 7, opacity: 1 } }).addTo(map);

    function pin(letter) {
      return L.divIcon({ className: "", html: `<div class="pin ${letter.toLowerCase()}">${letter}</div>`, iconSize: [30, 30], iconAnchor: [15, 15] });
    }

    const waypoints = {
      a: L.marker([47.259938, 11.393712], { icon: pin("A"), draggable: true }).addTo(map),
      b: L.marker([47.28187, 11.430896], { icon: pin("B"), draggable: true }).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 { lat, lng } = waypoints[key].getLatLng();
      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");
      for (const marker of [waypoints.a, waypoints.b]) {
        const { lat, lng } = marker.getLatLng();
        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");
      casing.clearLayers();
      route.clearLayers();
      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();
        return;
      }

      const path = data.paths[0];
      const line = path.points.coordinates;
      casing.addData(path.points);
      route.addData(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", () => step.clearLayers().addData(segment));
        item.addEventListener("mouseleave", () => step.clearLayers());

        // Instruction coordinates are [latitude, longitude], the same order Leaflet uses.
        item.addEventListener("click", () => map.flyTo(instruction.coordinate, 17));
        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 side = panel.width < window.innerWidth / 2;
        map.fitBounds(route.getBounds(), {
          paddingTopLeft: side ? [panel.right + 40, 60] : [40, panel.bottom + 30],
          paddingBottomRight: [60, 60],
          animate: false,
        });
      }
    }

    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();
      });
    });

    updateRoute();
    updateAddress("a");
    updateAddress("b");
<!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%; }
    #panel {
      position: absolute; top: 12px; left: 12px; bottom: 12px; z-index: 1000; 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;
    }
    .pin.a { background: #2f9e44; }
    .pin.b { background: #d6336c; }
    @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">&nbsp;</div><div class="details" id="details">&nbsp;</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 = L.map("map", { zoomControl: false }).setView([47.27, 11.405], 14);
    L.control.zoom({ position: "bottomright" }).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);

    // A white casing under the route line keeps it readable on any background.
    const casing = L.geoJSON(null, { style: { color: "#ffffff", weight: 9, opacity: 1 } }).addTo(map);
    const route = L.geoJSON(null, { style: { color: "#303f7e", weight: 5, opacity: 1 } }).addTo(map);
    const step = L.geoJSON(null, { style: { color: "#f59f00", weight: 7, opacity: 1 } }).addTo(map);

    function pin(letter) {
      return L.divIcon({ className: "", html: `<div class="pin ${letter.toLowerCase()}">${letter}</div>`, iconSize: [30, 30], iconAnchor: [15, 15] });
    }

    const waypoints = {
      a: L.marker([47.259938, 11.393712], { icon: pin("A"), draggable: true }).addTo(map),
      b: L.marker([47.28187, 11.430896], { icon: pin("B"), draggable: true }).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 { lat, lng } = waypoints[key].getLatLng();
      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");
      for (const marker of [waypoints.a, waypoints.b]) {
        const { lat, lng } = marker.getLatLng();
        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");
      casing.clearLayers();
      route.clearLayers();
      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();
        return;
      }

      const path = data.paths[0];
      const line = path.points.coordinates;
      casing.addData(path.points);
      route.addData(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", () => step.clearLayers().addData(segment));
        item.addEventListener("mouseleave", () => step.clearLayers());

        // Instruction coordinates are [latitude, longitude], the same order Leaflet uses.
        item.addEventListener("click", () => map.flyTo(instruction.coordinate, 17));
        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 side = panel.width < window.innerWidth / 2;
        map.fitBounds(route.getBounds(), {
          paddingTopLeft: side ? [panel.right + 40, 60] : [40, panel.bottom + 30],
          paddingBottomRight: [60, 60],
          animate: false,
        });
      }
    }

    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();
      });
    });

    updateRoute();
    updateAddress("a");
    updateAddress("b");
  </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 route planner in Innsbruck on Maptoolkit raster tiles, with draggable A and B markers, a side panel with a car, bike and walk switch, the reverse-geocoded address of each marker, the travel time, distance and climb, and turn-by-turn steps with direction arrows that highlight their part of the route on hover.

How it works

The A and B pins are L.marker with an L.divIcon and draggable: true. The route is drawn by two L.geoJSON layers, a wide white one under a narrower blue one, so it stays readable over any part of the map, and a third holds the highlighted step. Each new route goes in with clearLayers() and addData(). Leaflet also takes [latitude, longitude], so each instruction’s coordinate goes into map.flyTo() unchanged.

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.

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 Leaflet elevation profile example turns a route into a climb chart with the Elevation API.