Protomaps Basemap tiles

The whole Protomaps Basemap, zoom 0 to 15, served as one PMTiles archive over plain HTTP. Point any PMTiles client at the URL and it works.

https://tiles.jasontally.com/basemap.pmtiles

A blocked OpenStreetMap tile, or a 403 from tile.openstreetmap.org? Start here.

Try it

The same archive in three styles. Change the style and the tiles already loaded stay put, so it is instant.

z0

Make your own

Change a colour or switch a layer off and the map updates as you go. Then copy the style JSON, which is a normal MapLibre style you can host yourself. It points at these tiles, so it keeps working wherever you put it.

Use it

The archive speaks HTTP range requests, so every PMTiles client can read it.

One. Put a map on the page

Copy this file. It is the whole example: three tags, one style URL, one map. Both libraries are pinned and BSD-3-Clause, and the same files are served from this host if you would rather not use a CDN.

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Basemap</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css">
  <script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/pmtiles@3.2.1/dist/pmtiles.js"></script>
  <style>html, body, #map { margin: 0; height: 100%; }</style>
</head>
<body>
  <div id="map"></div>
  <script>
    // Teach MapLibre how to read a PMTiles archive.
    const protocol = new pmtiles.Protocol();
    maplibregl.addProtocol("pmtiles", protocol.tile);

    new maplibregl.Map({
      container: "map",
      style: "https://tiles.jasontally.com/styles/light.json",
      center: [2.35, 48.85],   // Paris
      zoom: 4
    });
  </script>
</body>
</html>

Two. Swap the tiles in a style you already have

Every MapLibre style reads its tiles from a source. To use these tiles, change that one url. Nothing else in the style changes.

  "sources": {
    "protomaps": {
      "type": "vector",
-     "url": "pmtiles://https://demo-bucket.protomaps.com/v4.pmtiles",
+     "url": "pmtiles://https://tiles.jasontally.com/basemap.pmtiles",
      "attribution": "<a href=\"https://www.openstreetmap.org/copyright\">&copy; OpenStreetMap contributors</a>"
    }
  }

Or do it in code, which is useful if the style comes from a server you do not control:

// Rewrite every PMTiles source in a style to point at these tiles.
const style = await (await fetch("https://your-server/style.json")).json();

for (const source of Object.values(style.sources)) {
  if (source.url?.startsWith("pmtiles://")) {
    source.url = "pmtiles://https://tiles.jasontally.com/basemap.pmtiles";
  }
}

new maplibregl.Map({ container: "map", style, center: [2.35, 48.85], zoom: 4 });

Three. Where to put the style you made

The Style JSON button in the builder above opens the style this map is drawing right now, edits included, and the dialog has a copy button. Two ways to use what it gives you, and both are ordinary web pages.

Save it as a file next to your page and point style at it, the way the three styles on this host are used. This is the one to choose if the style is yours and changes with your site:

// Save the copied JSON as my-basemap.json beside this file.
new maplibregl.Map({
  container: "map",
  style: "my-basemap.json",           // relative, same origin
  center: [2.35, 48.85],
  zoom: 4
});

Or keep it in the HTML, which is one file to move and nothing to host. The same style, only the place it lives is different:

// The copied JSON, pasted straight into the page.
const myStyle = {
  "version": 8,
  "name": "Protomaps Basemap — custom",
  "glyphs": "https://tiles.jasontally.com/font/{fontstack}/{range}.pbf",
  "sprite": "https://tiles.jasontally.com/sprites/v4/light",
  "sources": { /* ... as copied ... */ },
  "layers":  [ /* ... as copied ... */ ]
};

new maplibregl.Map({ container: "map", style: myStyle, zoom: 4 });

Leave the glyphs and sprite lines as they are. They are what draws the labels and the icons, and they point at this host, so a style you paste into your own page keeps working from wherever you serve it. Point them at protomaps.github.io/basemaps-assets instead if you would rather not depend on this host at all.

One thing to check before you swap. This archive is Protomaps schema v3. A style written for a different schema will load and then draw nothing, because the source layer names do not match. The nine layer names are listed below, and so is a ready-made v3 style you can copy from.

Four. Fonts, sprites and the libraries

A map is the tiles, a glyphs URL for text, optionally a sprite URL for icons, and the libraries to draw any of it. All of that is here, at the paths each one expects, so a map can come from a single hostname if you want it to.

WhatURL hereLicence

The MapLibre and PMTiles files are on jsDelivr and here. Either URL works.

The last row is not used by this page. It is the Leaflet vector renderer, published here so the swap is one script tag. Leaflet itself stays on its own CDN.

Two font stacks, Noto Sans Regular and Noto Sans Medium, split into the 256 ranges MapLibre asks for, 256 codepoints at a time. All 256 are published, not just the Latin ones: Protomaps labels features with their local name, so Tokyo or Cairo needs ranges in the tens of thousands. Publish only what an English map uses and those labels vanish, with no error to say why.

Five sprite sheets, one per upstream flavour, at 1x and 2x. The styles below draw icons from them, so these are fetched. If you want townspots, highway shields or points of place in a style of your own, point sprite at the one for your flavour. The icon names in a sheet are listed in its light.json.

What the map draws, and where each thing comes from

Every layer is one of the nine source layers in this archive, so nothing here needs a second archive. Two of them are worth calling out, because both look like something missing when they are not:

LayerComes fromIn this build?
Shapes, roads, buildings, water, landearth, landcover, landuse, water, roads, buildingsyes
Place, road and water namesthe name field on those same layersyes
Point of place icons, townspotspois.kind, places, plus the spriteyes
Route shieldsroads.shield_textno. The build being served does not have that field
House numbersbuildings.kind=address and buildings.addr_housenumberno, same reason

There is no separate address archive, and there is no address layer to fetch. The upstream project puts address points inside the buildings layer, as features with kind=address and an addr_housenumber. That field arrived in a later Protomaps build than the one this map is serving, so the house number layer is built and switches itself on when the next monthly refresh brings them in. The same is true of the shield text: the current build carries shield_text_length, which is how many characters a route number is and not what they are, so a shield drawn from it would be a blank shape on every motorway.

Writing a layer yourself, and colouring it

A layer is a filter, a zoom, a shape and a colour. Two things in the filter get everybody, and both fail without an error:

So a road, its name, and a toggle to switch the line off:

{
  "id": "road-major",
  "type": "line",
  "source": "protomaps",
  "source-layer": "roads",            // one of the nine
  "minzoom": 4,                                  // not drawn below this
  "filter": ["==", ["get", "kind"], "major_road"],
  "layout": {
    "line-cap": "round",
    "visibility": "visible"          // "none" to hide it
  },
  "paint": {
    "line-color": "#f7c44d",          // any CSS colour
    "line-width": ["interpolate", ["linear"], ["zoom"], 4, 1.7, 12, 6]
  }
}

A name is a symbol layer reading the name field, with a halo or nothing behind it. A missing glyph is silent, so the stack you ask for must be one of the two on this host:

{
  "id": "road-label-major",
  "type": "symbol",
  "source": "protomaps",
  "source-layer": "roads",
  "minzoom": 11,
  "layout": {
    "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
    "text-font": ["Noto Sans Medium"],  // or Noto Sans Regular
    "text-size": 10,
    "symbol-placement": "line"       // follow the road
  },
  "paint": {
    "text-color": "#2b2a26",
    "text-halo-color": "#ffffff",
    "text-halo-width": 1.1
  }
}

A layer that draws nothing is not an error, and MapLibre will not say which check failed. If yours is blank, check in this order: the field name, the layer's minzoom, the font stack, then the icon name against the sheet's own light.json.

If you have a style that points its glyphs at demotiles.maplibre.org, replacing that hostname is the whole change. The path is deliberately the same.

"glyphs": "https://tiles.jasontally.com/font/{fontstack}/{range}.pbf",
"sprite": "https://tiles.jasontally.com/sprites/v4/light"

// before, in a style you already have
- "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
+ "glyphs": "https://tiles.jasontally.com/font/{fontstack}/{range}.pbf",

If a style asks for a font stack that is not one of the two above, nothing will be drawn for it. Protomaps' own styles use Noto Sans Medium for bold text; there is no Noto Sans Bold in this set, which is why a style that asks for one silently loses every label.

Which host is faster, and what was decided about it. Measured rather than assumed: interleaved requests, one at a time, on one connection, first three samples dropped, time to first byte separated from the transfer. On that test a CDN answered 10 to 45 ms sooner for every one of these files, which is the whole 15% rule and a margin the samples clearly clear. The libraries load from jsDelivr for that reason.

The fonts and the sprites are served from here even though the same measurement says a CDN would answer them about 10 ms sooner. That is a decision, not a measurement. A map that comes from one hostname does not break when somebody else's does, and a glyph server that is down is a map with no labels. If you would rather have the 10 ms, point glyphs at protomaps.github.io/basemaps-assets/fonts and it works with the same path. One reader's numbers from one city, and re-running the comparison is one command.

Five. About Leaflet

Leaflet's L.tileLayer draws pictures; these are vector tiles. A Leaflet map can still use them, and either way keeps your markers, popups and controls. Both swaps are in Moving from OpenStreetMap tiles.

Six. Read the archive with the PMTiles library

No map, just data. Useful for scripts, tests and anything that is not a picture.

import { PMTiles, FetchSource } from "pmtiles";

const archive = new PMTiles(new FetchSource("https://tiles.jasontally.com/basemap.pmtiles"));

const header = await archive.getHeader();
console.log(header.minZoom, header.maxZoom);        // 0 15
console.log(header.numAddressedTiles);              // 1431655765

const metadata = await archive.getMetadata();
console.log(metadata.name);                          // "Protomaps Basemap"

const tile = await archive.getZxy(3, 4, 2);          // z, x, y
console.log(tile.data.byteLength);

Note the two classes. FetchSource is the byte reader over HTTP, and PMTiles is the reader that sits on top of it and understands the format. FileSource is the local-file equivalent of FetchSource; given a URL string it will not work.

The archive is a snapshot, not a live feed. How fresh is this? has the timing.

Moving from OpenStreetMap tiles

The tile usage policy block, the 403, and what to do about them. No key, no quota.

Why the map shows a block

App is not following the tile usage policy of OpenStreetMap’s volunteer-run servers

General block: app is not following the tile usage policy of openstreetmap volunteer-run servers

403 Forbidden — tile.openstreetmap.org

The tile servers sent that image instead of the map, on purpose, under the tile usage policy. It is not a fault, and fixing the URL does not clear it.

What people try first:

Offices, schools, VPNs and mobile networks share an address, and blocks apply to addresses as well as apps. From your side you cannot tell which you have.

So stop asking those servers. There are three ways off them.

Keep Leaflet

protomaps-leaflet draws vector tiles in Leaflet. It reads the archive itself and adds itself as a normal layer, so markers, popups, GeoJSON and controls stay where they are. A copy is here, tested against this archive: https://tiles.jasontally.com/vendor/protomaps-leaflet.js.

Before:

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

<script>
  const map = L.map("map").setView([48.85, 2.35], 12);

  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    maxZoom: 19,
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
  }).addTo(map);
</script>

After:

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+ <script src="https://tiles.jasontally.com/vendor/protomaps-leaflet.js"></script>

<script>
  const map = L.map("map").setView([48.85, 2.35], 12);

-  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { ... }).addTo(map);
+  protomapsL.leafletLayer({
+    url: "https://tiles.jasontally.com/basemap.pmtiles",
+    flavor: "light",
+    lang: "en"
+  }).addTo(map);
</script>

protomaps-leaflet is in maintenance mode upstream: fixes, not features. Fine for an existing Leaflet app. New work should start on MapLibre GL.

Keep Leaflet, render with MapLibre

@maplibre/maplibre-gl-leaflet puts a MapLibre map in Leaflet’s tile pane. Use it when you want one of the styles above, or terrain and hillshade, in an app that stays Leaflet.

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://tiles.jasontally.com/vendor/maplibre-gl.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://tiles.jasontally.com/vendor/maplibre-gl.js"></script>
<script src="https://tiles.jasontally.com/vendor/pmtiles.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-leaflet@0.1.4/dist/leaflet-maplibre-gl.js"></script>

<script>
  // The style's source is pmtiles://, so MapLibre needs the protocol first.
  maplibregl.addProtocol("pmtiles", new pmtiles.Protocol().tile);

  const map = L.map("map", { minZoom: 1 }).setView([48.85, 2.35], 12);

  const gl = L.maplibreGL({
    style: "https://tiles.jasontally.com/styles/light.json"
  }).addTo(map);

  // gl.getMaplibreMap() is the MapLibre map, for sources and layers.
</script>

Move to MapLibre GL

One library, no adapter, and the styles above work as they are. The full page is in One. Put a map on the page. The swap is mostly a coordinate flip:

LeafletMapLibre GL
L.map("map").setView([lat, lng], z)new maplibregl.Map({ container: "map", center: [lng, lat], zoom: z })
L.tileLayer(url).addTo(map)a style with a vector source; see above
L.marker([lat, lng]).bindPopup(html)new maplibregl.Marker().setLngLat([lng, lat]).setPopup(new maplibregl.Popup().setHTML(html))
L.geoJSON(data)addSource(id, { type: "geojson", data }), then addLayer
L.control.scale()new maplibregl.ScaleControl()
map.invalidateSize()map.resize()

setView([lat, lng]) takes latitude first; every MapLibre call takes [lng, lat]. A map in the wrong hemisphere is that, not the tiles. Sources and layers added before the style loads are dropped without an error, so add them in map.on("load", …).

“archive contains MVT vector tiles, but leafletRasterLayer is for displaying raster tiles”

That is pmtiles.leafletRasterLayer, which draws raster PMTiles as images. These are vector tiles, so it refuses them. No option changes that, and there is no raster copy here. Use one of the options above.

“download.openstreetmap.fr returned 403”

That is an extract and replication mirror, not a tile server. In October 2026 it blocked addresses after abusive downloads, and the operators said they would restore access case by case. Nothing here needs it: this archive comes from Protomaps releases, so you do not assemble .osm.pbf yourself. If you do pull OSM data, take a periodic extract rather than the minute stream.

Styles

Same tiles, different colours. Pick one, copy it, change it, it is yours.

StyleLooks likeFile

What is in the tiles

Read from the archive's own metadata at page load. These are the layer names a style must use.

Source layerZoomFields
loading…

What this is not

Plain answers, so nothing here surprises you later.

How fresh is this?

The tiles are a copy of the Protomaps Basemap, rebuilt once a month.

Style JSON

open raw