That's the whole thing explained. Here's the same component as a single, self-contained file -- copy it into any React/Next.js project and it works as-is.
"use client";
/**
* Drop-in MapLibre routing map -- click to add stops (2 or more), get a real
* route between them by car, bike, or on foot, via Valhalla.
*
* Setup: npm install maplibre-gl react react-dom
*
* Uses the public openstreetmap.de Valhalla demo server by default: free, no
* API key, CORS-enabled, but rate-limited and not meant for production
* traffic. Pass your own valhallaUrl to use a self-hosted or commercial
* instance instead.
*
* The worker script loads from a CDN (pin the version to match your
* installed maplibre-gl) because MapLibre's default worker-URL detection
* (import.meta.url) breaks under bundlers like Turbopack/Webpack.
*/
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { useEffect, useRef, useState } from "react";
export type TransportMode = "auto" | "bicycle" | "pedestrian";
export const TRANSPORT_MODES: { mode: TransportMode; label: string }[] = [
{ mode: "auto", label: "Car" },
{ mode: "bicycle", label: "Bike" },
{ mode: "pedestrian", label: "Walk" },
];
const DEFAULT_VALHALLA_URL = "https://valhalla1.openstreetmap.de/route";
interface RouteStep {
instruction: string;
distanceKm: number;
durationSeconds: number;
}
interface RouteResult {
coordinates: [number, number][];
distanceKm: number;
durationSeconds: number;
steps: RouteStep[];
}
// Google-style polyline decoding, but Valhalla uses 6 decimal places of
// precision instead of the usual 5.
function decodePolyline(encoded: string, precision = 6): [number, number][] {
const factor = 10 ** precision;
const coordinates: [number, number][] = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let shift = 0;
let result = 0;
let byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lat += result & 1 ? ~(result >> 1) : result >> 1;
shift = 0;
result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lng += result & 1 ? ~(result >> 1) : result >> 1;
coordinates.push([lng / factor, lat / factor]);
}
return coordinates;
}
interface ValhallaManeuver {
instruction: string;
length: number;
time: number;
}
interface ValhallaTripResponse {
trip: {
status: number;
status_message: string;
legs: { shape: string; maneuvers: ValhallaManeuver[] }[];
summary: { length: number; time: number };
};
}
const REQUEST_TIMEOUT_MS = 8000;
async function fetchRoute(
waypoints: [number, number][],
mode: TransportMode,
valhallaUrl: string,
): Promise<RouteResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(valhallaUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
locations: waypoints.map(([lng, lat]) => ({ lat, lon: lng })),
costing: mode,
units: "kilometers",
}),
signal: controller.signal,
});
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error("Routing server unreachable (timed out) — try again later");
}
throw err;
} finally {
clearTimeout(timeout);
}
if (!res.ok) throw new Error(`Routing request failed (${res.status})`);
const data = (await res.json()) as ValhallaTripResponse;
if (data.trip.status !== 0) {
throw new Error(data.trip.status_message || "No route found");
}
return {
coordinates: data.trip.legs.flatMap((leg) => decodePolyline(leg.shape)),
distanceKm: data.trip.summary.length,
durationSeconds: data.trip.summary.time,
steps: data.trip.legs.flatMap((leg) =>
leg.maneuvers.map((m) => ({
instruction: m.instruction,
distanceKm: m.length,
durationSeconds: m.time,
})),
),
};
}
const EMPTY_ROUTE: GeoJSON.Feature<GeoJSON.Geometry> = {
type: "Feature",
properties: {},
geometry: { type: "LineString", coordinates: [] },
};
function markerColor(index: number, total: number): string {
if (index === 0) return "#22c55e"; // start
if (index === total - 1) return "#ef4444"; // destination
return "#3b82f6"; // via point
}
const MODE_ICONS: Record<TransportMode, React.ReactNode> = {
auto: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16V11l1.6-4.8A2 2 0 0 1 7.5 5h9a2 2 0 0 1 1.9 1.2L20 11v5M4 16a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1H4v1Zm16 0a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1h3v1ZM4 11h16"
/>
<circle cx="7.5" cy="14" r="0.6" fill="currentColor" />
<circle cx="16.5" cy="14" r="0.6" fill="currentColor" />
</svg>
),
bicycle: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="5.5" cy="17.5" r="3.5" />
<circle cx="18.5" cy="17.5" r="3.5" />
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.5 17.5 10 8h4l4.5 9.5M10 8 8.5 5h-2M10 8l3 5.5h5.5"
/>
</svg>
),
pedestrian: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="13" cy="4.5" r="1.5" fill="currentColor" stroke="none" />
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 8 10 9.5l-2.5 3M13.5 8l1.5 4-1 6.5M13.5 8l3 2 2.5-1M15 12l-3.5 2-3 5.5"
/>
</svg>
),
};
export interface RoutingMapProps {
/** [lng, lat] to center the map on. */
origin?: [number, number];
/** Any MapLibre style URL. */
mapStyle?: string;
initialZoom?: number;
/** Valhalla /route endpoint. Defaults to a free public demo server. */
valhallaUrl?: string;
}
export default function RoutingMap({
origin = [10.1815, 36.7992],
mapStyle = "https://tiles.openfreemap.org/styles/liberty",
initialZoom = 14,
valhallaUrl = DEFAULT_VALHALLA_URL,
}: RoutingMapProps) {
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const markersRef = useRef<maplibregl.Marker[]>([]);
const [waypoints, setWaypoints] = useState<[number, number][]>([]);
const [mode, setMode] = useState<TransportMode>("auto");
const [summary, setSummary] = useState<{
distanceKm: number;
durationSeconds: number;
steps: RouteStep[];
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Create the map once and wire up click-to-add-waypoint. Everything that
// reacts to waypoints/mode changing lives in the effect below instead, so
// this one only needs to run again if the map's own setup props change.
useEffect(() => {
if (!mapContainerRef.current) return;
maplibregl.setWorkerUrl(
"https://unpkg.com/maplibre-gl@6.6.0/dist/maplibre-gl-worker.mjs",
);
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: mapStyle,
center: origin,
zoom: initialZoom,
canvasContextAttributes: { antialias: true },
});
mapRef.current = map;
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.on("load", () => {
map.addSource("route", { type: "geojson", data: EMPTY_ROUTE });
map.addLayer({
id: "route-line",
type: "line",
source: "route",
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": "#2563eb", "line-width": 5, "line-opacity": 0.85 },
});
});
const onClick = (e: maplibregl.MapMouseEvent) => {
setWaypoints((prev) => [...prev, [e.lngLat.lng, e.lngLat.lat]]);
};
map.on("click", onClick);
return () => {
map.off("click", onClick);
map.remove();
mapRef.current = null;
};
}, [origin, mapStyle, initialZoom]);
// Redraw markers and re-route whenever the waypoints or transport mode
// change. Using React state (not a ref) for waypoints means this effect
// always sees the current list -- no manual "latest value" plumbing needed.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = waypoints.map((point, i) =>
new maplibregl.Marker({ color: markerColor(i, waypoints.length) })
.setLngLat(point)
.addTo(map),
);
const routeSource = () => map.getSource("route") as maplibregl.GeoJSONSource | undefined;
if (waypoints.length < 2) {
routeSource()?.setData(EMPTY_ROUTE);
return;
}
let cancelled = false;
// Starting a loading flag right before kicking off the fetch it
// describes, not deriving state that belongs in render.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
setError(null);
fetchRoute(waypoints, mode, valhallaUrl)
.then((result) => {
if (cancelled) return;
routeSource()?.setData({
type: "Feature",
properties: {},
geometry: { type: "LineString", coordinates: result.coordinates },
});
setSummary({
distanceKm: result.distanceKm,
durationSeconds: result.durationSeconds,
steps: result.steps,
});
})
.catch((err: unknown) => {
if (cancelled) return;
routeSource()?.setData(EMPTY_ROUTE);
setSummary(null);
setError(err instanceof Error ? err.message : "Routing failed");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [waypoints, mode, valhallaUrl]);
return (
<>
<div ref={mapContainerRef} style={{ position: "absolute", inset: 0 }} />
{/* Plain CSS for the mode buttons' hover state -- simplest way to add
a hover rule from inside a single self-contained component file. */}
<style>{`.routing-mode-btn:hover:not(.routing-mode-btn-active) { background: rgba(255,255,255,0.08); }`}</style>
<div
style={{
position: "absolute",
top: 16,
left: "50%",
transform: "translateX(-50%)",
display: "flex",
flexDirection: "column",
gap: 10,
padding: "12px 14px",
borderRadius: 14,
background: "rgba(13, 17, 23, 0.78)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
border: "1px solid rgba(255,255,255,0.08)",
boxShadow: "0 10px 28px rgba(0,0,0,0.35)",
color: "#c9d1d9",
fontFamily: "ui-sans-serif, system-ui, sans-serif",
fontSize: 13,
minWidth: 260,
maxWidth: "calc(100vw - 32px)",
}}
>
<div style={{ display: "flex", gap: 6 }}>
{TRANSPORT_MODES.map(({ mode: m, label }) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`routing-mode-btn${mode === m ? " routing-mode-btn-active" : ""}`}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "7px 10px",
borderRadius: 8,
border: mode === m ? "1px solid #3b82f6" : "1px solid #30363d",
background: mode === m ? "#2563eb" : "transparent",
color: "#fff",
cursor: "pointer",
fontSize: 13,
boxShadow: mode === m ? "0 2px 10px rgba(37,99,235,0.45)" : "none",
transition: "background 0.12s, box-shadow 0.12s",
}}
>
{MODE_ICONS[m]}
{label}
</button>
))}
<button
type="button"
onClick={() => {
setWaypoints([]);
setSummary(null);
setError(null);
}}
className="routing-mode-btn"
title="Clear stops"
style={{
display: "flex",
alignItems: "center",
padding: "7px 10px",
borderRadius: 8,
border: "1px solid #30363d",
background: "transparent",
color: "#c9d1d9",
cursor: "pointer",
fontSize: 13,
transition: "background 0.12s",
}}
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
{waypoints.length < 2 && !error && (
<span style={{ color: "#8b949e" }}>
Click the map to add stops ({waypoints.length}/2+).
</span>
)}
{loading && <span style={{ color: "#8b949e" }}>Routing…</span>}
{error && <span style={{ color: "#f87171" }}>{error}</span>}
{summary && !loading && !error && (
<>
<span style={{ fontWeight: 600, color: "#e6edf3" }}>
{summary.distanceKm.toFixed(1)} km · {Math.round(summary.durationSeconds / 60)} min
</span>
<ol
style={{
margin: 0,
padding: 0,
listStyle: "none",
maxHeight: 220,
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{summary.steps.map((step, i) => (
<li
key={i}
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
padding: "4px 0",
borderTop: i > 0 ? "1px solid rgba(255,255,255,0.06)" : "none",
}}
>
<span
style={{
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 18,
height: 18,
marginTop: 1,
borderRadius: "50%",
background: "rgba(59,130,246,0.18)",
color: "#60a5fa",
fontSize: 11,
fontWeight: 600,
}}
>
{i + 1}
</span>
<span>
{step.instruction}
{step.distanceKm > 0 && (
<span style={{ color: "#8b949e" }}>
{" "}
({(step.distanceKm * 1000).toFixed(0)} m)
</span>
)}
</span>
</li>
))}
</ol>
</>
)}
</div>
</>
);
}