How to Serve a Web Map from PMTiles with MapLibre
Problem statement
The traditional web map needs a tile server: a process that receives /z/x/y.pbf, looks up the tile and returns it. That is a service to run, scale, monitor and secure.
PMTiles removes it. The whole pyramid is one file with a header and a directory of byte offsets, so a browser can fetch the header, then the directory entry, then the tile β three HTTP range requests, and after the first pan the header and directory are cached.
What remains is a static file host that supports Range, which every object store and CDN does.
Quick answer
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pmtiles@3/dist/pmtiles.js"></script>
<script>
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
buildings: {
type: "vector",
url: "pmtiles://https://example.com/buildings.pmtiles",
},
},
layers: [{
id: "buildings-fill",
type: "fill",
source: "buildings",
"source-layer": "buildings", // must match the MVT layer name
paint: { "fill-color": "#0ea5e9", "fill-opacity": 0.6 },
}],
},
center: [-2.24, 53.48],
zoom: 13,
});
</script>
source-layer is the single most common failure: it must equal the layer name inside the MVT, not the source name in the style.
Step-by-step solution
1. Register the protocol before creating the map
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
This teaches MapLibre what a pmtiles:// URL means. Registering it after the map is constructed produces a source that never loads and no error.
2. Point the source at the archive with the pmtiles:// prefix
url: "pmtiles://https://example.com/buildings.pmtiles"
Note the doubled scheme β the protocol prefix followed by the real URL. Omitting pmtiles:// makes MapLibre treat the file as a TileJSON document and fail to parse it.
3. Get source-layer right
A vector tile contains named layers. The style's source-layer must match one of them exactly.
If you generated the tiles yourself, it is the name you passed to the encoder. If not, read it from the archive's metadata:
from pmtiles.reader import Reader, MmapSource
with open("buildings.pmtiles", "rb") as f:
metadata = Reader(MmapSource(f)).metadata()
print([layer["id"] for layer in metadata.get("vector_layers", [])])
A mismatch renders nothing, silently. This is the most common cause of a blank PMTiles map.
4. Configure the host for ranges and CORS
The host must return 206 Partial Content for a Range request. Object stores and CDNs do; some application servers and older static servers do not.
If the page is on a different origin from the archive, the host must also send:
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Length, Content-Range, ETag
Without Access-Control-Expose-Headers, the browser receives the bytes and cannot read the Content-Range header, so the PMTiles client cannot work out what it got.
5. Set the zoom range from the archive
sources: {
buildings: {
type: "vector",
url: "pmtiles://.../buildings.pmtiles",
minzoom: 10,
maxzoom: 16,
},
}
Beyond maxzoom, MapLibre overzooms the deepest tiles rather than requesting tiles that do not exist. Getting this wrong produces 404s on every pan past the limit.
Code examples
Example 1 β a complete self-contained map
<!doctype html>
<meta charset="utf-8">
<title>PMTiles map</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<style>html, body, #map { height: 100%; margin: 0 }</style>
<div id="map"></div>
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pmtiles@3/dist/pmtiles.js"></script>
<script>
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const ARCHIVE = "https://example.com/buildings.pmtiles";
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
basemap: {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
attribution: "Β© OpenStreetMap contributors",
},
buildings: {
type: "vector",
url: "pmtiles://" + ARCHIVE,
minzoom: 10,
maxzoom: 16,
},
},
layers: [
{ id: "basemap", type: "raster", source: "basemap" },
{
id: "buildings-fill",
type: "fill",
source: "buildings",
"source-layer": "buildings",
paint: {
"fill-color": "#0ea5e9",
"fill-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0.2, 16, 0.7],
},
},
{
id: "buildings-outline",
type: "line",
source: "buildings",
"source-layer": "buildings",
minzoom: 15,
paint: { "line-color": "#1a3a6b", "line-width": 0.6 },
},
],
},
center: [-2.24, 53.48],
zoom: 13,
});
map.on("error", (e) => console.error("map error:", e && e.error));
map.addControl(new maplibregl.NavigationControl());
</script>
The map.on("error", ...) handler is worth having from the start. MapLibre reports missing tiles, failed sources and style problems there, and without it they are silent.
Adding the outline only above zoom 15 is the standard pattern: outlines on thousands of small polygons at low zoom produce a grey mush.
Example 2 β verifying the archive before debugging the map
import gzip
import mapbox_vector_tile as mvt
from pmtiles.reader import Reader, MmapSource
from pmtiles.tile import zxy_to_tileid
def verify_archive(path, sample=(13, 4090, 2670)):
"""Read the header, metadata and one tile β before touching the browser."""
with open(path, "rb") as handle:
reader = Reader(MmapSource(handle))
header = reader.header()
metadata = reader.metadata()
print(f" zooms {header['min_zoom']}-{header['max_zoom']}")
print(f" bounds {header['min_lon_e7'] / 1e7:.4f},"
f"{header['min_lat_e7'] / 1e7:.4f} .. "
f"{header['max_lon_e7'] / 1e7:.4f},"
f"{header['max_lat_e7'] / 1e7:.4f}")
layers = metadata.get("vector_layers")
if not layers:
print(" ! no vector_layers metadata β styles cannot be validated")
else:
for layer in layers:
print(f" source-layer '{layer['id']}': "
f"fields {sorted(layer.get('fields', {}))}")
data = reader.get(zxy_to_tileid(*sample))
if data is None:
print(f" ! no tile at z{sample[0]}/{sample[1]}/{sample[2]}")
return
decoded = mvt.decode(gzip.decompress(data))
for name, layer in decoded.items():
print(f" tile layer '{name}': {len(layer['features'])} features")
Running this first eliminates half the possible causes of a blank map. If the layer name in the tile does not match the source-layer in your style, the browser will never tell you.
Example 3 β checking the host supports what PMTiles needs
import requests
def check_hosting(url, origin=None):
"""Range support and CORS headers, which are what actually break."""
head = requests.head(url, timeout=30)
print(f" HEAD {head.status_code}, "
f"{int(head.headers.get('content-length', 0)) / 1e6:.2f} MB")
print(f" accept-ranges: {head.headers.get('accept-ranges', 'MISSING')}")
ranged = requests.get(url, headers={"Range": "bytes=0-16383"}, timeout=30)
print(f" range request: {ranged.status_code} "
f"({'ok' if ranged.status_code == 206 else 'NOT 206 β ranges unsupported'})")
print(f" content-range: {ranged.headers.get('content-range', 'MISSING')}")
if origin:
pre = requests.options(url, headers={
"Origin": origin,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "range",
}, timeout=30)
allow = pre.headers.get("access-control-allow-origin")
expose = pre.headers.get("access-control-expose-headers", "")
print(f" CORS allow-origin: {allow or 'MISSING'}")
print(f" CORS expose-headers: {expose or 'MISSING'}")
if "content-range" not in expose.lower():
print(" ! Content-Range is not exposed β the client cannot read "
"what it received")
return ranged.status_code == 206
A 200 instead of a 206 means the host ignored the range and sent the whole archive. The map still works, slowly, and downloads the entire pyramid on the first tile.
Explanation
Why three requests are enough
The PMTiles header is a fixed-size block at the start of the file giving the byte ranges of the root directory and the tile data.
A client fetches the header, reads the directory entry for the tile it wants β following one more level of directory for large archives β and fetches the tile's byte range.
After the first tile, the header and root directory are cached in the browser, so subsequent tiles are one request each. That is the same as a tile server, with no server.
Why source-layer catches everyone
A MapLibre style has three names that are easy to conflate: the source name in sources, the layer id in layers, and source-layer which refers to the layer inside the vector tile.
Only the third is determined by the data. Get it wrong and MapLibre requests tiles, receives them, finds no matching layer, and renders nothing β with no error, because an absent layer in a tile is a normal condition.
Read the archive's vector_layers metadata rather than guessing.
Why CORS needs more than allow-origin
A cross-origin range request is a preflighted request, so the host must permit the Range header.
More subtly, the browser hides response headers from cross-origin JavaScript unless they are named in Access-Control-Expose-Headers. The PMTiles client reads Content-Range to know which bytes it received, so without that header it gets the data and cannot use it.
The symptom is a map that works same-origin and fails cross-origin, which sends people looking in entirely the wrong place.
Why static hosting changes the economics
A tile server is a process: it needs deployment, scaling, monitoring, patching and a cost floor even at zero traffic.
A PMTiles archive on object storage behind a CDN has no process, scales to any traffic the CDN handles, and costs storage plus transfer. Updating it is a file upload.
The trade-off is that updates are whole-file. For a dataset regenerated daily that is fine; for one edited continuously, a tile server that can invalidate individual tiles is still the right choice.
Edge cases or notes
- Register the protocol before creating the map.
pmtiles://prefixes the real URL β the doubled scheme is correct.source-layermust match the MVT layer name, not the source name.- The host must return 206 for range requests; a 200 means it sent everything.
- Expose
Content-Rangein CORS, or cross-origin fails while same-origin works. - Set
minzoomandmaxzoomon the source to avoid 404s past the pyramid. - Attach
map.on("error", ...); MapLibre is otherwise very quiet. - Updating means replacing the archive. Version the URL or set cache headers accordingly.
Internal links
- PMTiles and MBTiles explained β the archive format
- How to build vector tiles from a GeoDataFrame in Python β producing the tiles
- Vector tiles explained: MVT, layers and why they are not GeoJSON β where layer names come from
- My web map is blank or the layer never appears β diagnosing a silent failure
- How to style data-driven colours in a web map β the paint properties
- How to add interactivity and popups to a web map β querying features
- Web map tiles explained: XYZ, zoom levels and the tile pyramid β the grid underneath
- Cloud-native geospatial explained β why range requests remove the server
FAQ
How do I serve PMTiles without a server?
Upload the archive to any static host that supports HTTP range requests, register the PMTiles protocol in MapLibre, and point a vector source at pmtiles:// plus the URL.
Why is my PMTiles map blank?
Most often source-layer does not match the layer name inside the tiles. Read the archive's vector_layers metadata and use the exact name.
What CORS headers do I need?
Access-Control-Allow-Origin, and Access-Control-Expose-Headers including Content-Range β otherwise the client receives bytes it cannot interpret.
How do I know if my host supports range requests?
Send a Range header and check for 206 Partial Content. A 200 means the host ignored it and sent the whole archive.
Do I need to set minzoom and maxzoom?
Yes, matching the archive. Otherwise MapLibre requests tiles beyond the pyramid and gets 404s on every pan.
Can I update a PMTiles archive in place?
No. Replace the file and manage caching β version the URL or set appropriate cache headers.
When is a tile server still the right answer?
When tiles change continuously and you need to invalidate individual ones, or when tiles are rendered on demand from a database.