Vector map tiles in Leaflet
Leaflet shows map images, and has no renderer for vector tiles with a style. The maplibre-gl-leaflet plugin puts a MapLibre map into Leaflet as a layer, so your Leaflet markers, popups and GeoJSON stay as they are. With script tags and with a bundler. Tested with Leaflet 1.9.4 and maplibre-gl-leaflet 0.1.4.
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/@maplibre/maplibre-gl-leaflet@0.1.4/leaflet-maplibre-gl.js"></script>
<style>#map { height: 480px; }</style>
<div id="map"></div>
<script>
const map = L.map("map").setView([47.37, 8.54], 13);
L.maplibreGL({
style: "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en"
}).addTo(map);
</script>
With script tags
The plugin needs the classic build of MapLibre, which loads with a <script src> tag and defines maplibregl. MapLibre 6 comes as ES modules only, so script tags use MapLibre 5; version 5.24.0 is the last. Leaflet layers go on top of the vector map as usual:
const map = L.map("map").setView([47.37, 8.54], 13);
const base = L.maplibreGL({
style: "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en"
}).addTo(map);
L.marker([47.3769, 8.5417]).addTo(map).bindPopup("Zürich HB").openPopup();
L.geoJSON(route, { style: { color: "#d0312d", weight: 4 } }).addTo(map);
Any of our nine styles works: light, dark, white, grayscale, black, outdoor, marine, relief and driving. Add &hillshade=1 for shading. Leaflet cannot tilt the map, so terrain=1 has no use here.
With a bundler and MapLibre 6
npm install leaflet maplibre-gl @maplibre/maplibre-gl-leaflet
This ran in a Vite 8 build with MapLibre 6.13.0:
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "maplibre-gl/dist/maplibre-gl.css";
import { setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
import markerShadow from "leaflet/dist/images/marker-shadow.png";
setWorkerUrl(workerUrl);
// the bundler moves the marker images: tell Leaflet where they are
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow });
const KEY = import.meta.env.VITE_OVERPASS_KEY;
const map = L.map("map").setView([47.37, 8.54], 13);
maplibreGL({
style: `https://api.overpass.ch/v1/${KEY}/maps/styles/light.json?lang=en`
}).addTo(map);
L.marker([47.3769, 8.5417]).addTo(map).bindPopup("Zürich HB").openPopup();
Two lines fix the errors we met on the way:
- Without
setWorkerUrl, the map stays empty and the console shows “Worker failed to load. Check that the worker URL is correct.” MapLibre 6 needs the call with every bundler; for webpack, see the MapLibre migration guide. - Without the marker images, markers are invisible and the network tab shows 404 for
marker-icon.pngandmarker-shadow.png. This is a Leaflet problem with every bundler.
The MapLibre map inside
getMaplibreMap() returns the MapLibre map, for anything Leaflet cannot do, such as a contour layer or a click on a feature of the base map:
const gl = base.getMaplibreMap();
gl.on("load", () => {
gl.addSource("contours", { type: "vector", url: "https://api.overpass.ch/v1/YOUR_KEY/maps/contours.json" });
gl.addLayer({ id: "contours", type: "line", source: "contours", "source-layer": "contours",
paint: { "line-color": "rgba(124, 92, 58, 0.5)" } });
});
The zoom levels differ by one: Leaflet zoom 13 is MapLibre zoom 12, because MapLibre counts with tiles of 512 pixels. The plugin converts between them.
Limits
- Leaflet cannot tilt or rotate the map. For 3D terrain or a rotating map, use MapLibre GL JS directly:
L.markerbecomesmaplibregl.Marker, and GeoJSON layers become sources and layers. - overpass.ch has no PNG map images. For a plain image tile layer, use a raster tile service.
- Your key is visible in the page. Create a key of the type “Maps only” in your dashboard and enter your website as an allowed referer, and
localhost:5173for the Vite dev server. - Tiles count as with MapLibre: one map tile per vector tile. Every account includes 100'000 a month.