MapLibre GL JS tutorial: your first map
MapLibre GL JS draws vector maps in the browser with WebGL, from a style that names the tiles, colours, fonts and icons. A map with our tiles, markers and popups, your own data as a layer, other styles and languages, and the controls. With script tags and with Vite. Tested with MapLibre GL JS 6.16.0.
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.16.0/dist/maplibre-gl.css">
<style>#map { height: 480px; }</style>
<div id="map"></div>
<script type="module">
import * as maplibregl from "https://unpkg.com/maplibre-gl@6.16.0/dist/maplibre-gl.mjs";
const map = new maplibregl.Map({
container: "map",
style: "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en",
center: [8.54, 47.375], // longitude, latitude
zoom: 13
});
map.addControl(new maplibregl.NavigationControl());
</script>
With script tags
MapLibre 6 comes as ES modules: load it in a <script type="module"> with an import from a CDN, and pin the version. The CSS file places the controls, markers and popups; without it they lose their positions. For a classic <script src> tag, use MapLibre 5, the last version with that build: maplibre-gl@5.24.0/dist/maplibre-gl.js defines the global maplibregl.
The centre is [longitude, latitude], the reverse of Leaflet. MapLibre counts zoom levels in tiles of 512 pixels, so zoom 12 in MapLibre shows the area of zoom 13 in Leaflet and OpenLayers.
With npm and Vite
npm install maplibre-gl
This ran in a Vite 8.3 build:
import "maplibre-gl/dist/maplibre-gl.css";
import { Map, NavigationControl, setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
setWorkerUrl(workerUrl);
const KEY = import.meta.env.VITE_OVERPASS_KEY;
const map = new Map({
container: "map",
style: `https://api.overpass.ch/v1/${KEY}/maps/styles/light.json?lang=en`,
center: [8.54, 47.375],
zoom: 13,
});
map.addControl(new NavigationControl());
- MapLibre reads the tiles in a web worker. With a bundler, MapLibre 6 does not find the worker file by itself: without
setWorkerUrlthe map stays empty and the console shows “Worker failed to load. Check that the worker URL is correct.” For webpack, see the MapLibre migration guide. - Vite reads
VITE_OVERPASS_KEYfrom a.envfile next topackage.json. The key still ends up in the JavaScript of the page, so limit it to your website (below).
Markers and popups
new maplibregl.Marker({ color: "#d0312d" })
.setLngLat([8.5402, 47.3782])
.setPopup(new maplibregl.Popup({ offset: 25 }).setText("Zürich HB"))
.addTo(map);
A marker is an HTML element above the map: a click opens its popup, and it stays when the style changes. For hundreds of points, draw them as a layer instead, as in the next section.
Your data as a layer
function addShops() {
map.addSource("shops", { type: "geojson", data: "shops.geojson" });
map.addLayer({
id: "shops",
type: "circle",
source: "shops",
paint: { "circle-radius": 7, "circle-color": "#d0312d", "circle-stroke-width": 2, "circle-stroke-color": "#fff" }
});
}
map.on("style.load", addShops);
map.on("click", "shops", (e) => {
new maplibregl.Popup().setLngLat(e.lngLat).setText(e.features[0].properties.name).addTo(map);
});
map.on("mouseenter", "shops", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "shops", () => { map.getCanvas().style.cursor = ""; });
datatakes a URL or a GeoJSON object, with coordinates in longitude and latitude.- Add sources and layers in
style.load, not inload.loadfires once, andmap.setStyleremoves your layers with the old style; we checked that the layer is gone after a change from light to dark.style.loadfires for every style, so the layer comes back. map.on("click", "shops", …)fires only for features of that layer, ande.featuresholds them.
Other styles, languages and dark mode
const style = (name) => `https://api.overpass.ch/v1/YOUR_KEY/maps/styles/${name}.json?lang=de`; const dark = matchMedia("(prefers-color-scheme: dark)").matches; const map = new maplibregl.Map({ container: "map", style: style(dark ? "dark" : "light"), center: [8.54, 47.375], zoom: 13 }); // later, for example from a button: map.setStyle(style("outdoor"));
There are nine styles: light, dark, white, grayscale and black by Protomaps, and our own outdoor, marine, relief and driving, shown on the map tiles page. lang sets the language of the labels; the 41 languages are in the documentation.
Controls
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
map.addControl(new maplibregl.GeolocateControl({ trackUserLocation: true }));
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }));
map.addControl(new maplibregl.FullscreenControl());
The credits in the corner come with the map. Keep them: the OpenStreetMap licence requires the credit.
Hillshading, 3D terrain and contour lines
const map = new maplibregl.Map({
container: "map",
style: "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/outdoor.json?terrain=1",
center: [7.6586, 45.9763], // the Matterhorn
zoom: 12.5,
pitch: 70,
maxPitch: 85
});
terrain=1 adds 3D terrain to any style, hillshade=1 shading. The details are in the guides on 3D terrain, hillshading and contour lines; whole maps in a hiking map and a nautical map.
Your key and what counts
- 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. - Every vector tile and terrain tile counts as one map tile; styles, fonts, icons and tiles the browser already has are free. Every account includes 100'000 map tiles a month.
Other libraries
The same map in Leaflet and OpenLayers, as a React component with react-map-gl, and the changes for code written for Mapbox GL JS. How the three libraries differ: MapLibre vs Leaflet vs OpenLayers.