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
- No API keyNothing to sign up for, no token in your code.
- No rate limitNo throttling, no quota, no burst cap.
- MonthlyRebuilt from the newest Protomaps release once a month.
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.
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\">© 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.
| What | URL here | Licence |
|---|
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:
| Layer | Comes from | In this build? |
|---|---|---|
| Shapes, roads, buildings, water, land | earth, landcover, landuse, water, roads, buildings | yes |
| Place, road and water names | the name field on those same layers | yes |
| Point of place icons, townspots | pois.kind, places, plus the sprite | yes |
| Route shields | roads.shield_text | no. The build being served does not have that field |
| House numbers | buildings.kind=address and buildings.addr_housenumber | no, 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:
kindis the coarse name. For roads it is one ofhighway,major_road,minor_road,path,rail. The names in the upstream documentation —motorway,primary,residential— arekind_detailvalues, and a layer that filterskindagainst them draws nothing and says so nowhere.- A layer is not drawn below its own
minzoom. The archive stops at z15, so a layer whose minzoom is 16 is never drawn at all. Most features also carry their ownmin_zoom, the zoom at which the data says they are worth showing, and the shipped styles test that too.
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:
- Changing
a/b/cto the canonical host does not unblock you. The canonical URL is required, but the block is attached to the app or the Referer that was seen, not to the hostname. - Retrying makes it worse. Volume is part of what the block is for.
- No header trick rescues a page opened from disk.
file://sends noReferer. SendingX-Requested-Withinstead would need a CORS preflight for every tile, and the tile servers refuse that preflight too. Firefox and Chromium differ in which headers they send; the rule is the same for both. - Works on
localhost, blocked on the live site? That is the origin. The Referer for your domain is what gets blocked, andlocalhostsends a different one.
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: '© <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>
flavorislight,dark,white,grayscaleorblack. Those are the library’s themes, not the styles above, so the colours will not match exactly.- Labels are canvas text in the system font. No glyph URL, no font that can 404.
- z15 is the deepest tile. The layer overzooms past it, and
maxDataZoom: 15is already the default.
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>
- Register the protocol first. Without the
addProtocolline the console saysURL scheme "pmtiles" is not supportedand the map stays blank. - MapLibre draws one zoom below Leaflet. Its tiles are 512 px against Leaflet’s 256, so a layer with
minzoom12 appears when Leaflet reaches z13. - Leaflet still draws on top. The canvas sits in the tile pane, so markers and overlays behave as they do over a raster layer.
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:
| Leaflet | MapLibre 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.
| Style | Looks like | File |
|---|
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 layer | Zoom | Fields |
|---|---|---|
| loading… | ||
What this is not
Plain answers, so nothing here surprises you later.
- There is no service level guarantee. This is a free service with no contract behind it. It can be slow, it can go down, and the URL and the data can change or disappear without notice. If your product depends on it, host your own copy of the tiles or buy a supported plan from someone.
- It is never current to the hour. The copy is a snapshot of the newest Protomaps release, not a live feed. Edits made today are not in it. How fresh is this? has the timing.
- No whole-file download. Requests are answered in ranges of at most 512 KiB, which is all a PMTiles client ever asks for. Asking for the whole archive in one request returns
416, which is correct. - Attribution is required. The tiles are derived from OpenStreetMap. Keep the attribution line in your style or on your map.
How fresh is this?
The tiles are a copy of the Protomaps Basemap, rebuilt once a month.
- Rebuilt once a month. The build runs on the first Sunday of the month, between 00:30 and 07:00 UTC, the only window in which upstream has not published in three years of release history. Monthly and not weekly, because a new archive invalidates the cached parts on the CDN and in every browser that already has them, and each rebuild costs build time.
- Nothing to do most months. A rebuild only happens when upstream has published a new release.
- Which upstream build is in use right now? Use the Archive metadata button at the top of this page. It reads the archive's own header, so the answer is always current rather than something this page could have gone stale about.
- A rebuild is not instantaneous. It is thirteen separate rebuilds spread over about six and a half hours, one at a time. For most of that window the copy is a mix of two upstream builds. A tile you request during it may come from the newer build rather than the one the reader advertised, which shows as a tile from the wrong place, not as a broken map.
- A rebuild can be missed. It is a best-effort job, not a guarantee. If one step fails, the rest of the copy stays on the previous upstream build. It is still a working map.