How to Draw a Routing API Route on a Leaflet Map
- API or service
- Routing API + Leaflet
- Task
- Ordered stops → route GeoJSON → Leaflet route line and markers
- Examples
- Difficulty
- Beginner
- Time
- 10 min
Draw a Routing API route on a Leaflet map
You have two or more stops and want to show the road route between them on an interactive Leaflet map. The Routing API calculates the route and returns its geometry as GeoJSON. Leaflet renders that geometry as a line on top of a base map.
The main integration detail is coordinate order. The waypoints request parameter and Leaflet both use latitude, longitude, while GeoJSON route coordinates use [longitude, latitude]. L.geoJSON() swaps the order for you; a hand-built L.polyline() does not.
This guide draws a driving route in Seattle, Washington, from Pike Place Market (85 Pike Street) to the Space Needle (400 Broad Street) and on to the Seattle Central Library (1000 4th Avenue). It uses Geoapify raster tiles for the base map.
Task flow: ordered stops → Routing API (format=geojson) → L.geoJSON() route line → fitted map view → stop markers → optional per-leg colors and distance and time popups.
Request the route geometry
The stop coordinates come from geocoding the three addresses with the Geocoding API and rounding the results to six decimal places:
| Stop | Address | Latitude | Longitude |
|---|---|---|---|
| 1 | Pike Place Market, 85 Pike Street, Seattle, WA 98101 | 47.608717 | -122.340267 |
| 2 | Space Needle, 400 Broad Street, Seattle, WA 98109 | 47.620513 | -122.349304 |
| 3 | Seattle Central Library, 1000 4th Avenue, Seattle, WA 98104 | 47.606597 | -122.332053 |
Send the stops in visit order. Each waypoint is a latitude,longitude pair, and | separates the waypoints:
https://api.geoapify.com/v1/routing?waypoints=47.608717,-122.340267|47.620513,-122.349304|47.606597,-122.332053&mode=drive&apiKey=YOUR_API_KEY
| Parameter | Value | Purpose |
|---|---|---|
waypoints |
three lat,lon pairs |
Ordered stops. The Routing API also accepts lonlat:lon,lat when your data is already in longitude-first order. |
mode |
drive |
Travel mode that selects the road network and access rules. |
format |
omitted | Defaults to geojson, which Leaflet reads directly. |
units |
omitted | Defaults to metric: distance is in meters. time is always in seconds. |
Read the GeoJSON response
The default response is a GeoJSON FeatureCollection with one Feature for the route. Its geometry is a MultiLineString with one line per leg: three stops produce two legs, so coordinates contains two lines. In this response the first line has 119 points and the second has 88. The reduced response keeps the first and last point of each line.
Reduced response:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"mode": "drive",
"units": "metric",
"distance": 4286,
"distance_units": "meters",
"time": 540.524,
"waypoints": [
{"location": [-122.340267, 47.608717], "original_index": 0},
{"location": [-122.349304, 47.620513], "original_index": 1},
{"location": [-122.332053, 47.606597], "original_index": 2}
],
"legs": [
{"distance": 2113, "time": 275.158},
{"distance": 2172, "time": 265.366}
]
},
"geometry": {
"type": "MultiLineString",
"coordinates": [
[[-122.340312, 47.608767], [-122.348841, 47.620471]],
[[-122.348841, 47.620471], [-122.331917, 47.606653]]
]
}
}
]
}
geometry.coordinates[i]is the path oflegs[i]. The last point of a leg is the first point of the next leg.- Every coordinate is
[longitude, latitude], as GeoJSON requires. properties.waypoints[].locationrepeats the request coordinates in[longitude, latitude]order. The route line starts and ends on the nearest road instead, so its first point ([-122.340312, 47.608767]) is not the requested Pike Place Market coordinate. The Add stop markers section explains which location to mark.- The whole route is 4,286 m and 540.524 s: 4,286 ÷ 1,609.344 ≈ 2.7 miles and 540.524 ÷ 60 ≈ 9 minutes.
Compare format=json
Add format=json when your application mainly reads route objects, for example for turn-by-turn directions. The route is the same, but it moves to results[0], and each geometry point becomes an object:
Reduced response:
{
"results": [
{
"distance": 4286,
"time": 540.524,
"geometry": [
[{"lon": -122.340312, "lat": 47.608767}, {"lon": -122.348841, "lat": 47.620471}],
[{"lon": -122.348841, "lat": 47.620471}, {"lon": -122.331917, "lat": 47.606653}]
]
}
]
}
L.geoJSON() cannot read this structure. Use the default GeoJSON format for map display, or convert each point to [point.lat, point.lon] for L.polyline().
| Value | Coordinate order |
|---|---|
waypoints request parameter |
latitude,longitude (or lonlat:longitude,latitude) |
GeoJSON geometry.coordinates and properties.waypoints[].location |
[longitude, latitude] |
format=json geometry points |
{"lon": …, "lat": …} objects |
Leaflet L.marker(), L.polyline(), fitBounds() |
[latitude, longitude] |
Handle errors
The Routing API returns HTTP 400 with a message when it cannot build the route. Two messages point to common input mistakes:
| Request problem | message |
|---|---|
Longitude sent first, such as -122.340267,47.608717 |
Failed to parse location |
| Stops that no road connects, such as Seattle and Honolulu | Locations are in unconnected regions. Please check waypoint coordinate order (lat/lon). |
Show the message to the user or log it, and do not draw an empty layer.
Draw the route with Leaflet
Create the Leaflet map with a Geoapify raster tile layer, request the route, and pass the whole GeoJSON response to L.geoJSON(). The page needs Leaflet's CSS and JavaScript and an element such as <div id="map" style="height: 480px"></div>.
const apiKey = "YOUR_API_KEY";
// Stops in visit order: user input, latitude first.
const stops = [
{ name: "Pike Place Market", lat: 47.608717, lon: -122.340267 },
{ name: "Space Needle", lat: 47.620513, lon: -122.349304 },
{ name: "Seattle Central Library", lat: 47.606597, lon: -122.332053 }
];
const map = L.map("map");
// Geoapify raster tiles. Leaflet replaces {r} with "@2x" on high-density screens.
L.tileLayer("https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}{r}.png?apiKey={apiKey}", {
apiKey,
maxZoom: 20,
attribution: 'Powered by <a href="https://www.geoapify.com/">Geoapify</a> | © OpenMapTiles <a href="https://www.openstreetmap.org/copyright">© OpenStreetMap</a> contributors'
}).addTo(map);
async function fetchRoute(stops) {
const params = new URLSearchParams({
waypoints: stops.map((stop) => `${stop.lat},${stop.lon}`).join("|"),
mode: "drive",
apiKey
});
const response = await fetch(`https://api.geoapify.com/v1/routing?${params}`);
const body = await response.json();
if (!response.ok) {
throw new Error(body.message || `Routing API error ${response.status}`);
}
return body; // GeoJSON FeatureCollection
}
const route = await fetchRoute(stops);
// L.geoJSON reads [longitude, latitude] and creates one polyline per feature.
const routeLayer = L.geoJSON(route, {
style: () => ({
color: "#1565c0",
weight: 6,
opacity: 0.85,
lineCap: "round",
lineJoin: "round"
})
}).addTo(map);
// Zoom to the route instead of hardcoding a center and zoom level.
map.fitBounds(routeLayer.getBounds(), { padding: [32, 32] });
The Routing API produces the geometry and route measurements. Leaflet only draws them: L.geoJSON() turns the MultiLineString into one L.polyline with two parts, converts each coordinate to a Leaflet LatLng, and applies the style result to it. Because both legs belong to one feature, they share one style; split them as shown in Color legs and show distance and time when they need different colors.
The map has no initial setView(). fitBounds() sets the center and zoom from the route bounds, and padding keeps the line away from the map edges. For this route, the line bounds run from 47.606653 to 47.620874 latitude and from -122.353357 to -122.331917 longitude. The western edge comes from the first leg's path along Western Avenue and Elliott Way, not from a stop coordinate, which is why the bounds should come from the route layer rather than from the stops.
The {apiKey} placeholder is a Leaflet template value filled from the apiKey option. In a browser, the API key is visible to users, so configure the key's allowed origins for your application or call the Routing API through your backend.
await at the top level works in an ES module (<script type="module">). In a classic script, wrap the code in an async function and handle the rejected promise, for example by showing error.message instead of the map overlay.
Add stop markers
A stop has two positions after routing:
| Position | Where it comes from | Example: Space Needle |
|---|---|---|
| Requested location | Your waypoints input, also echoed in properties.waypoints[].location as [longitude, latitude] |
47.620513, -122.349304 |
| Route endpoint | First or last point of a leg in geometry.coordinates, where the Routing API joined the road network |
47.620471, -122.348841 |
For the Space Needle the two points are about 35 m apart, because the geocoded point is inside Seattle Center and the car route ends on the nearest drivable road. For Pike Place Market and the library the gaps are about 7 m and 12 m.
This guide places the markers on the requested locations, because they mark the places the user chose. A thin dashed line joins each marker to the route endpoint, so the last walk from the road stays visible. Put the markers on the route endpoints instead when your map shows only the drivable path, such as a vehicle stop on the curb.
// Numbered markers from the Geoapify Marker Icon API.
function stopIcon(number) {
return L.icon({
iconUrl: `https://api.geoapify.com/v2/icon/?type=awesome&color=%231565c0&size=42&scaleFactor=2&text=${number}&apiKey=${apiKey}`,
iconSize: [32, 47],
iconAnchor: [16, 42],
popupAnchor: [0, -36]
});
}
const legLines = route.features[0].geometry.coordinates; // [longitude, latitude]
const toLatLng = ([lon, lat]) => [lat, lon];
// Route endpoint per stop: start of the first leg, then the end of each leg.
const routeEnds = [legLines[0][0], ...legLines.map((line) => line[line.length - 1])].map(toLatLng);
stops.forEach((stop, index) => {
const requested = [stop.lat, stop.lon];
const popup = document.createElement("span");
popup.textContent = stop.name;
L.polyline([requested, routeEnds[index]], {
color: "#1565c0",
weight: 2,
dashArray: "4 6"
}).addTo(map);
L.marker(requested, { icon: stopIcon(index + 1), title: stop.name })
.bindPopup(popup)
.addTo(map);
});
The stops come from the request input, so the markers stay in the same order as the request and keep the names. If you only have the response, read route.features[0].properties.waypoints instead and swap each location from [longitude, latitude] to [latitude, longitude]. original_index gives the position of the stop in the request.
The endpoint lookup assumes one leg between each pair of stops. That is true for the default intermediate_waypoint_mode=stopover and for through_stop. With pass_through, intermediate stops do not split the route, so only the first and last stops have leg endpoints.
The Marker Icon API returns a 64 × 94 PNG for size=42&scaleFactor=2. Leaflet shows it at 32 × 47 CSS pixels, and iconAnchor: [16, 42] puts the marker tip on the location above the 5-pixel shadow. See How to Add Markers to a Map for icon sizes and anchors in other map libraries.
Color legs and show distance and time (optional)
L.geoJSON() draws the whole MultiLineString as one layer with one style. To give each leg its own color and popup, build one L.polyline() per line in geometry.coordinates and read the matching properties.legs[i]. Because you create these polylines yourself, swap every coordinate from [longitude, latitude] to [latitude, longitude].
Use this code instead of the single L.geoJSON() layer, not in addition to it.
const legColors = ["#1565c0", "#ef6c00"];
const formatMiles = (meters) => `${(meters / 1609.344).toFixed(1)} mi`;
const formatMinutes = (seconds) => `${Math.round(seconds / 60)} min`;
const feature = route.features[0];
const legLayers = feature.geometry.coordinates.map((line, index) => {
const leg = feature.properties.legs[index];
const latLngs = line.map(([lon, lat]) => [lat, lon]); // GeoJSON → Leaflet order
const popup = document.createElement("div");
const title = document.createElement("strong");
title.textContent = `${stops[index].name} → ${stops[index + 1].name}`;
popup.append(title, document.createElement("br"),
`${formatMiles(leg.distance)}, ${formatMinutes(leg.time)}`);
return L.polyline(latLngs, {
color: legColors[index % legColors.length],
weight: 6,
opacity: 0.85
})
.bindPopup(popup)
.addTo(map);
});
const routeGroup = L.featureGroup(legLayers);
map.fitBounds(routeGroup.getBounds(), { padding: [32, 32] });
Without the swap, Leaflet reads -122.340312 as a latitude. Leaflet does not reject that value, so there is no error: the line is drawn at the southern edge of the world map instead of in Seattle, and fitBounds() zooms there.
The popups use the units of the default units=metric request: distance in meters and time in seconds. For this route they show:
| Leg | Response distance |
Miles | Response time |
Minutes |
|---|---|---|---|---|
| Pike Place Market → Space Needle | 2113 | 2113 ÷ 1609.344 = 1.3 | 275.158 | 275.158 ÷ 60 ≈ 5 |
| Space Needle → Seattle Central Library | 2172 | 2172 ÷ 1609.344 = 1.3 | 265.366 | 265.366 ÷ 60 ≈ 4 |
Whole route (properties.distance, properties.time) |
4286 | 2.7 | 540.524 | 9 |
When the request includes units=imperial, the Routing API returns distance in miles (2.6635 for the route; 1.3134 and 1.3501 for the legs) and sets distance_units to miles. time stays in seconds. In that case, skip the meter-to-mile conversion.
For a route-wide popup on the single L.geoJSON() layer, bind properties.distance and properties.time the same way:
routeLayer.bindPopup((layer) => {
const { distance, time } = layer.feature.properties;
return `${formatMiles(distance)}, ${formatMinutes(time)}`;
});
Route map with an AI coding agent
Copy this prompt into Codex, Claude Code, Cursor, or another coding agent to add a Routing API route map with Leaflet to your project.
Add a route map to my project: calculate a route with the Geoapify Routing API and draw it on a Leaflet map.
Before you start
- Inspect the existing project first. Reuse its framework, components, styling, HTTP client, state management, and tests. Make the smallest necessary changes.
- If Leaflet is not installed, add it and its CSS the way the project adds other dependencies.
- Ask only what the project does not answer: where the stops come from, the travel mode, and whether distances are shown in miles or kilometers.
- Read the Geoapify API key from the project's existing environment configuration. Never hardcode, print, or log it. If the browser calls the API directly, tell me to restrict the key to the app's origins; otherwise call the Routing API through the existing backend.
Base map
- Use a Leaflet tile layer with https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}{r}.png?apiKey=..., maxZoom 20, and the attribution "Powered by Geoapify | © OpenMapTiles © OpenStreetMap contributors" with links.
API request
- GET https://api.geoapify.com/v1/routing with waypoints as latitude,longitude pairs joined by "|", in visit order, plus mode. Keep the default format (GeoJSON).
- On a non-2xx response, show the response "message" and do not draw a route.
Drawing
- The response is a FeatureCollection with one Feature. Its geometry is a MultiLineString with one line per leg; coordinates are [longitude, latitude].
- Draw the whole route with L.geoJSON(response), which handles the coordinate order, then call map.fitBounds(layer.getBounds(), { padding: [32, 32] }).
- If you build L.polyline layers yourself (for example one color per leg), convert every [lon, lat] to [lat, lon]. Match geometry.coordinates[i] with properties.legs[i].
- Place numbered markers on the requested stop coordinates, not on the route line. Draw a thin dashed line from each marker to its route endpoint: the first point of the first leg, then the last point of each leg.
- Popups: distance is in meters (miles with units=imperial); time is always in seconds.
- Remove the previous route, markers, and connectors before drawing a new route, and ignore responses from outdated requests.
Tests
- Add a test with a mocked two-leg response that checks the coordinate swap, the leg endpoints, and the error message path.
Review the generated code against the request and Leaflet pages of this guide. Check the coordinate order in the request, the response, and every hand-built Leaflet layer; the units in popups; the tile attribution; and how the API key reaches the browser.