Skip to content

50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / integrations · raw .md

Integrations: Leaflet, OpenLayers, deck.gl, Cesium, QGIS

MapMap's map surface is made of formats other people's software already reads: Mapbox Vector Tiles, TileJSON 3.0, MapLibre GL style JSON, PMTiles archives, and SDF glyph ranges. Nothing on it is a MapMap file format. So the map renders in clients that have never heard of MapMap, and this page is the copy-pasteable version of that for the five asked about most.

For MapLibre GL JS itself, and for the @mapmap/maps wrapper around it, see Maps, tiles & Studio. For MapLibre Native on iOS and Android, see Mobile maps. This page is everything else.

Set up your shell once; the snippets below use the same two values:

sh
export BASE=https://api.mapmap.ai   # or your self-host gateway
export API_KEY=snk_…                # issued by POST /v1/keys; see the quickstart

What MapMap serves, and what each client can read

SurfaceURLKeyRead by
Vector tiles (MVT)$BASE/tiles/{territory}/{z}/{x}/{y}.mvtyesEverything on this page
TileJSON 3.0$BASE/tiles/{territory}/tiles.jsonyesLeaflet (via MapLibre), OpenLayers, deck.gl, QGIS
Compiled MapLibre style$BASE/tiles/{territory}/style.jsonyesLeaflet (via MapLibre), OpenLayers (via ol-mapbox-style)
House styles, keyless$BASE/styles/mapmap-light-76ef8d@2.jsonnoLeaflet, via MapLibre with the pmtiles protocol registered
PMTiles archivehttps://tiles.mapmap.ai/{territory}.pmtilesnoMapLibre, deck.gl, GDAL and QGIS
SDF glyphshttps://fonts.mapmap.ai/{fontstack}/{range}.pbfnoMapLibre only

.pbf is accepted as a tile extension wherever .mvt is, which matters for clients whose URL templates assume it (QGIS is one). The key goes on as ?api_key=snk_… or as Authorization: Bearer snk_…; both are verified to work on the tile route, and the query form exists because a style document referenced by bare URL cannot attach a header.

Four things that decide every snippet below

1. There are no raster tiles. MapMap serves vector tiles and expects the client to render them. Server-side raster or PNG tile rendering is not offered, and neither is a {z}/{x}/{y}.png endpoint. Any client that can only consume raster tiles (Leaflet's own L.tileLayer, Cesium's UrlTemplateImageryProvider) cannot show a MapMap basemap. That is a real limit, not a configuration you have missed. The one raster surface is static map images, which renders a whole picture rather than a tile pyramid.

2. The key travels the way you sent it. GET /tiles/{territory}/style.json?api_key=snk_… returns a style whose source points at https://api.mapmap.ai/tiles/uk/tiles.json?api_key=snk_…, and that TileJSON in turn lists …/{z}/{x}/{y}.mvt?v=…&api_key=snk_…. So a client that follows the document verbatim stays authenticated all the way down to the tiles, and the style-based snippets below need no request hook.

Send the key as Authorization: Bearer snk_… instead and the URLs come back keyless, which is right for that client: it is attaching the header itself on every fetch, and a key it kept out of the URL is not going to be written into one for it. Pick one form per client rather than mixing them. A hook that puts the key on is still the answer when the document was fetched some other way, out of a build step or from a proxy of your own, but it is no longer load-bearing for the snippets here.

3. An in-range tile with no data is 204, not 404. Ocean, and the sparse edges of a territory. Treat an empty body as an empty tile; a client that logs 204 as a failure will look broken over the sea.

4. tiles.mapmap.ai gates browser origins. The PMTiles CDN is our own surface, and a request declaring an Origin or Referer it does not recognise is refused:

json
{
  "error": "hosted basemap tiles require an API key from this origin",
  "tiles_api": "https://api.mapmap.ai/tiles/:territory/:z/:x/:y.pbf",
  "signup": "https://mapmap.ai/signup"
}

localhost and 127.0.0.1 are allowed, so the keyless snippets here run on a development server. A request declaring neither header, which is what QGIS, GDAL, curl and a native app send, is served. A request from your own deployed site is not: ship the keyed tile API for anything on a domain of your own. This is the same gate described in Mobile maps.

Leaflet

Leaflet has no vector-tile renderer of its own. The working combination is @maplibre/maplibre-gl-leaflet, which puts a MapLibre GL canvas inside a Leaflet layer: Leaflet keeps the map object, the controls, the markers and the plugin ecosystem, MapLibre does the drawing. The library is small and the integration is one call.

Pin MapLibre 5 for this recipe. @maplibre/maplibre-gl-leaflet is a classic script that reads the maplibregl browser global. MapLibre 6 is ESM-only and creates no global, and the plugin has shipped no ESM build, so the working combination is still MapLibre 5.x. Pin it explicitly rather than floating: maplibre-gl@5 on a CDN will stop resolving the UMD file the day the tag moves.

html
<meta charset="utf-8" />
<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>
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-leaflet@0.1.1/leaflet-maplibre-gl.js"></script>

<div id="map" style="height: 100vh"></div>

<script>
  const API_KEY = "snk_…";

  const map = L.map("map", { center: [51.5074, -0.1278], zoom: 12 }); // lat, lon

  // The key on the style URL carries through to the TileJSON and the
  // tiles, so no transformRequest hook is needed.
  L.maplibreGL({
    style: "https://api.mapmap.ai/tiles/uk/style.json?api_key=" + API_KEY,
  }).addTo(map);

  map.attributionControl.addAttribution(
    '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>' +
      ' &copy; <a href="https://openmaptiles.org/">OpenMapTiles</a>'
  );
</script>

If you are already on MapLibre 6 elsewhere in the app, the plugin still works provided you put the namespace on the global before the plugin script evaluates, but you then own two MapLibre majors on one page, which is exactly the [duplicate-maplibre] failure. Prefer MapLibre GL JS directly.

Notes that save an hour:

  • Leaflet takes [lat, lon]; MapLibre and every MapMap API take [lon, lat]. The two coordinate orders sit in the same file here.
  • The container needs an explicit height, as it does for plain MapLibre. No height means a canvas zero pixels tall and a page that looks blank with no error. See troubleshooting a blank map.
  • Leaflet's own zoom, pan and inertia drive the MapLibre camera, so bearing and pitch are not available through this layer. If you need a tilted or rotated map, you want MapLibre GL JS directly.
  • L.tileLayer cannot be pointed at MapMap. It wants raster tiles and there are none.

For evaluation without a key, swap the style for a house style. It reads the PMTiles archive over range requests, so register the protocol first:

html
<script src="https://cdn.jsdelivr.net/npm/pmtiles@4.4.0/dist/pmtiles.js"></script>
<script>
  // `maplibregl` here is the MapLibre 5 global the pinned script above
  // created. MapLibre 6 creates no global; see the note on the recipe.
  maplibregl.addProtocol("pmtiles", new pmtiles.Protocol().tile);
  L.maplibreGL({
    style: "https://api.mapmap.ai/styles/mapmap-light-76ef8d@2.json",
  }).addTo(map);
</script>

That path is subject to the origin gate above, so it is for local work and demos rather than for a site you ship.

OpenLayers

ol-mapbox-style reads a MapLibre or Mapbox GL style document and configures OpenLayers layers from it, which means the MapMap style is the whole integration: colours, zoom ranges, filters and label placement all come across.

html
<meta charset="utf-8" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@10.7.0/ol.css" />
<script src="https://cdn.jsdelivr.net/npm/ol@10.7.0/dist/ol.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ol-mapbox-style@12.6.1/dist/olms.js"></script>

<div id="map" style="height: 100vh"></div>

<script>
  const API_KEY = "snk_…";

  const map = new ol.Map({
    target: "map",
    view: new ol.View({ center: ol.proj.fromLonLat([-0.1278, 51.5074]), zoom: 12 }),
    controls: ol.control.defaults
      .defaults({ attribution: false })
      .extend([new ol.control.Attribution({ collapsible: false })]),
  });

  olms
    .apply(map, "https://api.mapmap.ai/tiles/uk/style.json?api_key=" + API_KEY, {
      // MapMap ships labels as SDF glyphs, which OpenLayers cannot read.
      // Point webfonts at a real CSS for the family you want labels set in.
      webfonts:
        "https://cdn.jsdelivr.net/npm/@fontsource/noto-sans/{fontweight}{-fontstyle}.css",
    })
    .then(() => {
      map.getLayers().forEach(
        (l) =>
          l.getSource() &&
          l.getSource().setAttributions(
            '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>' +
              ' &copy; <a href="https://openmaptiles.org/">OpenMapTiles</a>'
          )
      );
    });
</script>

Notes:

  • Fonts are the one thing that does not carry over. MapLibre renders labels from the SDF glyph ranges at fonts.mapmap.ai; OpenLayers draws text with the browser's own font engine and ignores them. ol-mapbox-style tries to resolve the style's fontstack name (MapMap Sans Regular) against the Fontsource CDN, finds nothing, and falls back to a system font. Pass webfonts as a template string, as above, to choose the fallback deliberately. It is a template, not a function: passing a function throws t.replace is not a function and the map paints nothing.
  • webfonts is a string with {font-family}, {fontweight} and {-fontstyle} placeholders. The example pins one family for every stack, which is usually what you want when the style names a font you do not license.
  • OpenLayers collapses the attribution into an i button by default. The collapsible: false control above keeps the OpenStreetMap and OpenMapTiles credits on screen, which is a licence condition rather than a style preference.
  • Terrain, hillshade and 3D building extrusions in a MapMap theme have no OpenLayers equivalent and are dropped. The 2D map is complete.

deck.gl

deck.gl is the right client when the map is a substrate for your own data and you want the road network as geometry rather than as a picture. MVTLayer reads our tiles directly.

html
<meta charset="utf-8" />
<script src="https://cdn.jsdelivr.net/npm/deck.gl@9.1.14/dist.min.js"></script>

<div id="map" style="position: absolute; inset: 0; background: #12161c"></div>
<div style="position: absolute; right: 0; bottom: 0; z-index: 1;
     font: 12px/1.6 system-ui, sans-serif; background: rgba(0,0,0,.6); color: #fff; padding: 0 6px">
  &copy; <a style="color:#9cf" href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>
  &copy; <a style="color:#9cf" href="https://openmaptiles.org/">OpenMapTiles</a>
</div>

<script>
  const API_KEY = "snk_…";

  const roads = new deck.MVTLayer({
    id: "mapmap-roads",
    data: `https://api.mapmap.ai/tiles/uk/{z}/{x}/{y}.mvt?api_key=${API_KEY}`,
    minZoom: 0,
    maxZoom: 14,
    // Read one source layer out of the tile rather than all sixteen.
    loadOptions: { mvt: { layers: ["transportation"] } },
    getLineColor: (f) =>
      f.properties.class === "motorway" ? [232, 119, 34] : [110, 130, 155],
    getLineWidth: (f) => (f.properties.class === "motorway" ? 24 : 8),
    lineWidthMinPixels: 0.4,
    pickable: true,
  });

  new deck.DeckGL({
    container: document.getElementById("map"),
    initialViewState: { longitude: -0.1278, latitude: 51.5074, zoom: 11 },
    controller: true,
    layers: [roads],
  });
</script>

Notes:

  • Either the tile template or the TileJSON URL works, as long as the key is on the URL you hand over. MVTLayer accepts both, and a TileJSON fetched with ?api_key= lists tile templates that carry the key on, so the tiles it goes on to fetch are authenticated. Handing it a TileJSON URL with no key on it still ends in 401 on every tile.
  • loadOptions.mvt.layers is worth setting. A MapMap tile carries up to sixteen source layers (aerodrome_label, aeroway, boundary, building, housenumber, landcover, landuse, mountain_peak, park, place, poi, transportation, transportation_name, water, water_name, waterway); without a filter every one of them is parsed and styled by the same accessors.
  • deck.gl draws no attribution of its own. The credit above is plain HTML over the canvas, and it is not optional.
  • There is no MapMap style to hand deck.gl. You write the accessors. If you want the MapMap cartography underneath your data, run MapLibre as a base map and deck.gl as an overlay (MapboxOverlay in interleaved mode), which is the standard deck.gl and MapLibre pairing and needs nothing specific to MapMap.

deck.gl over a PMTiles archive

For an unmetered read of a whole territory, point deck.gl at the PMTiles archive through @loaders.gl/pmtiles. Pin it to a 4.x that matches the loaders.gl your deck.gl release bundles; @loaders.gl/pmtiles@4.3.4 against deck.gl@9.1.14 is the pairing checked here:

html
<script src="https://cdn.jsdelivr.net/npm/deck.gl@9.1.14/dist.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@loaders.gl/pmtiles@4.3.4/dist/dist.min.js"></script>

<script>
  const source = loaders.PMTilesSource.createDataSource(
    "https://tiles.mapmap.ai/uk.pmtiles",
    {}
  );

  const roads = new deck.TileLayer({
    id: "mapmap-roads-pmtiles",
    minZoom: 0,
    maxZoom: 14,
    getTileData: ({ index }) =>
      source.getVectorTile({
        ...index,
        zoom: index.z,
        loadOptions: {
          mvt: { coordinates: "wgs84", tileIndex: index, layers: ["transportation"] },
        },
      }),
    renderSubLayers: (props) =>
      new deck.GeoJsonLayer({
        id: props.id,
        data: props.data,
        stroked: false,
        filled: false,
        getLineColor: [110, 130, 155],
        getLineWidth: 8,
        lineWidthMinPixels: 0.4,
      }),
  });
</script>

MVTLayer does not accept a PMTiles source object as its data (it treats data as a URL and throws Invalid URL), which is why this uses TileLayer with an explicit getTileData. The loadOptions.mvt block is where the coordinate mode and the tile index go; without coordinates: "wgs84" and tileIndex the features come back with null coordinates and nothing draws. Remember the origin gate: this reads tiles.mapmap.ai, so it works from localhost and from a native context, and not from your own domain.

Cesium

Be plain about this one: MapMap has no basemap for Cesium.

  • Our tiles are Mapbox Vector Tiles. Cesium's 3D basemap format is 3D Tiles. They are different formats for different things, and MapMap publishes no 3D Tiles tileset.
  • Cesium's imagery providers want raster tiles, and MapMap serves none. Rendering our vector tiles to raster in a headless browser and feeding Cesium the result is a thing people do; it is not something MapMap offers, supports or prices, so nothing here describes how.

What does work is using MapMap as the data layer over whatever imagery your Cesium scene already has. A route with geometries=geojson, and an isochrone's contours, are ordinary GeoJSON, and Cesium draws them without any adapter:

html
<meta charset="utf-8" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1.145.0/Build/Cesium/Widgets/widgets.css" />
<script src="https://cdn.jsdelivr.net/npm/cesium@1.145.0/Build/Cesium/Cesium.js"></script>
<style>html, body, #c { height: 100%; margin: 0 }</style>

<div id="c"></div>
<div style="position:absolute;right:0;bottom:0;z-index:1;font:12px system-ui;
     background:rgba(0,0,0,.6);color:#fff;padding:0 6px">
  &copy; <a style="color:#9cf" href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>
</div>

<script>
  window.CESIUM_BASE_URL = "https://cdn.jsdelivr.net/npm/cesium@1.145.0/Build/Cesium/";

  (async () => {
    const viewer = new Cesium.Viewer("c", { baseLayer: false, baseLayerPicker: false });
    viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#12161c");

    // The keyless demo lane: standard profiles need no key.
    const r = await fetch(
      "https://api.mapmap.ai/route/v1/driving/-0.1278,51.5074;-1.5106,52.4081" +
        "?overview=full&geometries=geojson"
    ).then((x) => x.json());

    viewer.entities.add({
      polyline: {
        positions: Cesium.Cartesian3.fromDegreesArray(
          r.routes[0].geometry.coordinates.flat()
        ),
        width: 5,
        clampToGround: true,
        material: Cesium.Color.fromCssColorString("#e87722"),
      },
    });
    await viewer.zoomTo(viewer.entities);
  })();
</script>

baseLayer: false is what lets this run with no Cesium ion token; add your own imagery and terrain in its place. Swap the URL for POST /isochrone and the pattern is the same, except that its contours are a FeatureCollection, so Cesium.GeoJsonDataSource.load takes the response directly.

Point clouds are a separate story. MapMap's own LiDAR and photogrammetry rendering ships as @mapmap/points, which draws inside a MapLibre WebGL context, not a Cesium one.

QGIS

QGIS reads our tiles two ways, and they behave differently enough to be worth choosing between.

As a vector tiles connection (styled, keyed)

LayerAdd LayerAdd Vector Tile Layer…, then New for a connection. The dialog fields, and what to put in them:

FieldValue
NameAnything, for example MapMap UK
Source URLhttps://api.mapmap.ai/tiles/uk/{z}/{x}/{y}.pbf?api_key=snk_…
Min. Zoom Level0
Max. Zoom Level14
Style URLLeave empty (see below)
RefererLeave empty

Check the URL before you paste it into a dialog that will not tell you why it failed:

sh
curl -fsS -o tile.pbf -w '%{http_code} %{size_download} %{content_type}\n' \
  "$BASE/tiles/uk/12/2046/1362.pbf?api_key=$API_KEY"
# 200 212340 application/vnd.mapbox-vector-tile

A 401 means the key did not travel; a 204 means you asked for a tile with nothing in it, which is a real answer and not an error.

QGIS applies its own default cartography to the layer, one style per geometry type. Leaving Style URL empty is still the simpler path: the key now travels from a ?api_key= style URL through to the tiles, but the style also names SDF fontstacks QGIS does not render, so the labels are the part that will not come across. Style the layer in QGIS instead, or export a style from Studio as a reference for the colours.

As a PMTiles archive (unstyled, keyless)

QGIS reads PMTiles through GDAL, which has a PMTiles driver, and GDAL can read it in place over HTTP range requests. LayerAdd LayerAdd Vector Layer…, source type Protocol: HTTP(S), cloud, etc., and the URI:

bash
/vsicurl/https://tiles.mapmap.ai/uk.pmtiles

QGIS has no Origin header to send, so the browser-origin gate does not apply. Confirm what you are about to open first:

sh
ogrinfo -so "/vsicurl/https://tiles.mapmap.ai/uk.pmtiles"
ini
INFO: Open of `/vsicurl/https://tiles.mapmap.ai/uk.pmtiles'
      using driver `PMTiles' successful.
Metadata:
  attribution=<a href="https://www.openmaptiles.org/" …>&copy; OpenMapTiles</a> …
  name=OpenMapTiles
  ZOOM_LEVEL=14
1: aerodrome_label
2: aeroway
3: boundary
…
16: waterway

This opens as sixteen ordinary vector layers you can query, filter, join and run geoprocessing against, which the vector tiles connection does not give you. It is not a styled basemap: there is no cartography, and every layer arrives with QGIS's default symbology. Use the connection above when you want a backdrop, and this when you want the data.

Territory slugs are listed in the territories guide; planet.pmtiles is the worldwide archive and is large, so prefer a territory archive when one covers your area of interest.

MapLibre Native

iOS and Android are covered on their own page, because the failure modes are different: an origin-restricted key cannot work in a native app, and offline rendering comes from an installed territory package rather than from a URL. See Mobile maps with MapLibre Native, and SDKs & installation for the install coordinates.

Attribution

Map data derives from OpenStreetMap and the tile schema from OpenMapTiles. Anything you render or republish must credit © OpenStreetMap contributors © OpenMapTiles, with a link to openstreetmap.org/copyright.

MapLibre reads the credit off the style's tile source and renders it for you. Every other client on this page does not, which is why each snippet carries the credit explicitly:

  • Leaflet: map.attributionControl.addAttribution(…).
  • OpenLayers: an attribution control with collapsible: false, so the credit is on screen rather than behind a button.
  • deck.gl and Cesium: plain HTML over the canvas.
  • QGIS: the archive metadata carries it; put it on any map you export.

Removing the credit is a licence breach, not a style choice. See licensing for the ODbL boundary.

How these snippets were checked

Every browser snippet on this page was run against the live hosted gateway in a headless Chromium (Playwright 1.63, SwiftShader WebGL) served from http://127.0.0.1, with the network log recorded and the rendered page screenshotted. The bar was: no 4xx or 5xx on any request, and a canvas with real map content rather than a single flat colour.

SnippetResult
Leaflet, keyed style9 MapMap requests, all 200, tiles rendered
Leaflet, keyless house stylestyle 200, 8 PMTiles range reads 206, tiles rendered
OpenLayers, ol-mapbox-style9 MapMap requests, all 200, labels and roads rendered
deck.gl, MVTLayer over the keyed tile API6 tile requests, all 200, road network rendered
deck.gl, TileLayer over uk.pmtiles9 range reads, all 206, road network rendered
Cesium, route geometry over an empty globeroute 200, 154 km polyline drawn, no ion token

The QGIS URLs were checked with curl and ogrinfo (GDAL 3.13.3), whose output is quoted above. The QGIS dialog fields are the labels QGIS uses, not a paraphrase; the client versions in the snippets are the ones that were run, and pinning them is the difference between a snippet that works and one that used to.

Next steps