How to Serve a Web Map from PMTiles with MapLibre

On this page

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 — HTTP range requests against a static file, with the header and directory cached after the first tile.

What remains is a static file host that supports Range, which every object store and CDN does. Everything on this page was tested: a PMTiles archive of Rutland's 5,513 OpenStreetMap buildings, built with GDAL, served from a local host whose range and CORS behaviour can be switched off one at a time, and loaded by MapLibre GL JS 4 and the PMTiles 3 client in headless Chromium.

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: [-0.726, 52.670],
  zoom: 15,
});
</script>

The same page, pointed at the Rutland archive and loaded in a browser:

result = render(page(f"pmtiles://{TILES}/full/buildings.pmtiles"))
print(result)
{'buildings drawn': 383, 'requests': 14, 'error': None}

source-layer is the single most common failure: it must equal the layer name inside the MVT, not the source name in the style.

A browser fetching a PMTiles header, then a directory entry, then a tile by byte range from static hosting. three range requests, no server processheaderfixed size, cacheddirectory entrybyte range of the tilethe tileone range requestlater tilesone request eachAfter the first pan the header and directory are cached in the browser spatialworkflow.io
Three range requests and no server process. The header and directory are cached after the first tile.

Register the protocol, point the source, match the layer

1. Register the protocol before the map needs a tile

const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);

This teaches MapLibre what a pmtiles:// URL means. The safe order is before new maplibregl.Map(...), but what matters is when the style first asks for the source:

for label, when in (("before the map", "before"), ("right after new Map()", "after"),
                    ("two seconds later", "late")):
    print(f"registered {label:22}", render(page(f"pmtiles://{TILES}/full/buildings.pmtiles", register=when)))
registered before the map         {'buildings drawn': 383, 'requests': 14, 'error': None}
registered right after new Map()  {'buildings drawn': 383, 'requests': 14, 'error': None}
registered two seconds later      {'buildings drawn': 0, 'requests': 0, 'error': 'Failed to fetch'}

Registering straight after new Map() still worked, because the style loads asynchronously and the protocol was in place before the first request. Registering it later — after the source had already tried to load — left the map empty: MapLibre had handed the pmtiles:// URL to the browser's fetch, which reported "Failed to fetch" through map.on("error") and made no request at all.

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. Without it:

print(render(page(f"{TILES}/full/buildings.pmtiles")))
{'buildings drawn': 0, 'requests': 1, 'error': 'Unexpected token \'P\', "PMTiles\x03\x7f\x00"... is not valid JSON'}

MapLibre fetched the file as a TileJSON document and failed to parse the binary archive as JSON.

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 layer name you gave the encoder. If not, read it from the archive's metadata:

with open("data/buildings.pmtiles", "rb") as f:
    metadata = Reader(MmapSource(f)).metadata()
print([layer["id"] for layer in metadata.get("vector_layers", [])])

print("style says 'building':", render(page(f"pmtiles://{TILES}/full/buildings.pmtiles", source_layer="building")))
['buildings']
style says 'building': {'buildings drawn': 0, 'requests': 14, 'error': None}

The tiles arrived, MapLibre decoded them, found no layer called building, and drew nothing — with no error, because a tile that lacks a layer is a normal condition. 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, and a cross-origin page needs Access-Control-Allow-Origin. Here is each requirement removed in turn, with the page on one origin and the archive on another:

for label, mode in (("ranges + CORS + exposed headers", "full"), ("ranges + CORS, nothing exposed", "noexpose"),
                    ("ranges, no CORS headers", "nocors"), ("CORS, ranges ignored (200)", "norange")):
    print(f"{label:32}", render(page(f"pmtiles://{TILES}/{mode}/buildings.pmtiles")))
ranges + CORS + exposed headers  {'buildings drawn': 383, 'requests': 14, 'error': None}
ranges + CORS, nothing exposed   {'buildings drawn': 383, 'requests': 14, 'error': None}
ranges, no CORS headers          {'buildings drawn': 0, 'requests': 1, 'error': 'Failed to fetch'}
CORS, ranges ignored (200)       {'buildings drawn': 0, 'requests': 1, 'error': 'Server returned no content-length header or content-length exceeding request. Check that y'}

Two of these fail outright. Without Access-Control-Allow-Origin the browser blocks the response and the client sees only "Failed to fetch". A host that ignores Range and sends the whole file with a 200 does not give you a slow map: the PMTiles client refuses it, with a message that names the problem.

Access-Control-Expose-Headers turned out not to matter for this archive. The PMTiles client reads Content-Range only when the server answers 416 — which happens when the archive is smaller than its first 16 KB read — and reads ETag to notice that the archive was replaced mid-session. Exposing both costs one header line:

Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Range
Access-Control-Expose-Headers: Content-Length, Content-Range, ETag

5. Let the archive set the zoom range

With a pmtiles:// URL, the protocol builds the source's TileJSON from the archive header, including its minimum and maximum zoom. This archive stops at zoom 15; zoom the map to 18 without setting maxzoom:

print("zoom 18:", render(page(f"pmtiles://{TILES}/full/buildings.pmtiles"), zoom=18))
zoom 18: {'buildings drawn': 24, 'requests': 18, 'error': None}

MapLibre overzoomed the zoom-15 tiles and requested nothing beyond the pyramid. Setting minzoom/maxzoom on the source is only needed when you want a narrower range than the archive holds — or when the source is a plain tiles: [...] template, which carries no header to read.

A style referring to a source-layer name that does not exist in the tiles, producing a blank map with no error. three names, only one determined by the datathe style✓sources: { buildings: … }✓layers: [{ id: "buildings-fill" }]✓"source-layer": "buildings"✓all three easy to conflatethe tile✕contains layer "building"✕no layer called "buildings"✕tiles fetch successfully✕nothing renders, nothing logsAn absent layer in a tile is a normal condition, so it cannot raise spatialworkflow.io
source-layer refers to the layer inside the tile, not the source in the style. A mismatch renders nothing and logs nothing.

A self-contained map and an archive check

The data used on this page

Rutland's buildings from Geofabrik's OpenStreetMap extract of 1 January 2026 (© OpenStreetMap contributors, ODbL), written to PMTiles by GDAL's PMTiles driver at zooms 10 to 15. Two local HTTP servers stand in for a web page and a static host: the host serves the archive under paths that switch range support and CORS headers on or off, and render() loads a MapLibre page in headless Chromium and counts the buildings it drew.

import re
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import requests
from osgeo import gdal
from pmtiles.reader import MmapSource, Reader
from playwright.sync_api import sync_playwright

gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
if not Path("data/rutland.shp.zip").exists():
    Path("data/rutland.shp.zip").write_bytes(requests.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
if not Path("data/buildings.pmtiles").exists():
    gdal.VectorTranslate("data/buildings.pmtiles", "/vsizip/data/rutland.shp.zip/gis_osm_buildings_a_free_1.shp",
                         format="PMTiles", layerName="buildings", selectFields=["osm_id", "type"],
                         datasetCreationOptions=["MINZOOM=10", "MAXZOOM=15"])
ARCHIVE = Path("data/buildings.pmtiles").read_bytes()
PAGES, REQUESTS = {}, []


class Host(BaseHTTPRequestHandler):
    """/full/, /noexpose/, /nocors/ and /norange/ serve the same archive under different rules."""
    protocol_version = "HTTP/1.1"
    def log_message(self, *args):
        pass
    def reply(self, status, body=b"", headers=()):
        self.send_response(status)
        for key, value in headers:
            self.send_header(key, value)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)
    def do_OPTIONS(self):
        cors = self.path.split("/")[1] != "nocors"
        self.reply(204, headers=[("Access-Control-Allow-Origin", "*"), ("Access-Control-Allow-Headers", "Range"),
                                 ("Access-Control-Allow-Methods", "GET, HEAD")] if cors else [])
    def do_GET(self):
        if self.path.startswith("/page/"):
            return self.reply(200, PAGES[self.path[6:]].encode(), [("Content-Type", "text/html")])
        mode = self.path.split("/")[1]
        REQUESTS.append(self.headers.get("Range"))
        headers = [("Content-Type", "application/octet-stream"), ("ETag", '"rutland-1"')]
        if mode != "nocors":
            headers.append(("Access-Control-Allow-Origin", "*"))
        if mode in ("full", "norange"):
            headers.append(("Access-Control-Expose-Headers", "Content-Length, Content-Range, ETag"))
        found = re.match(r"bytes=(\d+)-(\d*)", self.headers.get("Range") or "")
        if found and mode != "norange":
            first = int(found.group(1))
            last = min(int(found.group(2) or len(ARCHIVE) - 1), len(ARCHIVE) - 1)
            return self.reply(206, ARCHIVE[first:last + 1], headers + [
                ("Content-Range", f"bytes {first}-{last}/{len(ARCHIVE)}"), ("Accept-Ranges", "bytes")])
        return self.reply(200, ARCHIVE, headers)
    do_HEAD = do_GET


servers = [ThreadingHTTPServer(("127.0.0.1", 0), Host) for _ in range(2)]
for server in servers:
    server.daemon_threads = True
    threading.Thread(target=server.serve_forever, daemon=True).start()
SITE, TILES = (f"http://127.0.0.1:{s.server_address[1]}" for s in servers)     # two origins


def page(url, source_layer="buildings", register="before"):
    protocol = 'const protocol = new pmtiles.Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile);'
    template = """<!doctype html><meta charset="utf-8"><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>
window.errors = [];
BEFORE
const map = new maplibregl.Map({ container: "map", center: [-0.726, 52.670], zoom: 15, style: { version: 8,
  sources: { buildings: { type: "vector", url: "URL" } },
  layers: [ { id: "buildings-fill", type: "fill", source: "buildings", "source-layer": "LAYER",
              paint: { "fill-color": "#0ea5e9", "fill-opacity": 0.6 } } ] } });
map.on("error", e => window.errors.push(String(e && e.error && e.error.message)));
AFTER
</script>"""
    late = "setTimeout(() => { " + protocol + " }, 2000);"
    return (template.replace("URL", url).replace("LAYER", source_layer)
            .replace("BEFORE", protocol if register == "before" else "")
            .replace("AFTER", protocol if register == "after" else late if register == "late" else ""))


def render(html, zoom=None, wait=6):
    """Load the page in Chromium; return buildings drawn, map errors and archive requests."""
    name = f"p{len(PAGES)}"
    PAGES[name] = html
    REQUESTS.clear()
    with sync_playwright() as p:
        browser = p.chromium.launch()
        tab = browser.new_page()
        tab.goto(f"{SITE}/page/{name}")
        tab.wait_for_function("window.map !== undefined || typeof map !== 'undefined'", timeout=30000)
        if zoom:
            tab.evaluate(f"map.setZoom({zoom})")
        time.sleep(wait)
        drawn = tab.evaluate("map.queryRenderedFeatures({layers: ['buildings-fill']}).length")
        errors = tab.evaluate("window.errors")
        browser.close()
    return {"buildings drawn": drawn, "requests": len(REQUESTS),
            "error": errors[0][:90] if errors else None}


print(f"archive: {len(ARCHIVE) / 1e6:.2f} MB | site {SITE != TILES and 'and tiles on different origins'}")
archive: 0.78 MB | site and tiles on different origins

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,
    sources: {
      buildings: { type: "vector", url: "pmtiles://" + ARCHIVE },
    },
    layers: [
      { id: "background", type: "background", paint: { "background-color": "#f8fafc" } },
      {
        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: [-0.726, 52.670],
  zoom: 15,
});

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 failed sources and style problems there — every failure in step 4 surfaced through it — 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. For a basemap under the buildings, use a tile service whose terms allow your traffic; OpenStreetMap's own tile servers are not for production use.

Example 2 — verifying the archive before debugging the map

import gzip

import mapbox_vector_tile as mvt
from pmtiles.tile import zxy_to_tileid


def verify_archive(path, sample=(15, 16317, 10722)):
    """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']}, tiles {header['tile_type'].name} "
              f"compressed {header['tile_compression'].name}")
        print(f"  bounds {header['min_lon_e7'] / 1e7:.4f},{header['min_lat_e7'] / 1e7:.4f} .. "
              f"{header['max_lon_e7'] / 1e7:.4f},{header['max_lat_e7'] / 1e7:.4f}")
        for layer in metadata.get("vector_layers") or []:
            print(f"  source-layer '{layer['id']}': fields {sorted(layer.get('fields', {}))}")
        data = reader.get(*sample)
        if data is None:
            print(f"  ! no tile at z{sample[0]}/{sample[1]}/{sample[2]}")
            return
        for name, layer in mvt.decode(gzip.decompress(data)).items():
            print(f"  tile z{sample[0]}/{sample[1]}/{sample[2]} layer '{name}': {len(layer['features'])} features")

verify_archive("data/buildings.pmtiles")
  zooms 10-15, tiles MVT compressed GZIP
  bounds -0.8149,52.5292 .. -0.4481,52.7529
  source-layer 'buildings': fields ['osm_id', 'type']
  tile z15/16317/10722 layer 'buildings': 63 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.

What MapLibre drew for each PMTiles setup in the headless-browser test, and what it reported. eight setups, one archive, Chromiumbuildings drawnmap erroras documented✓383noneprotocol after Map()✓383noneprotocol 2 s late✕0Failed to fetchno pmtiles:// prefix✕0not valid JSONwrong source-layer✕0!none — silentCORS, none exposed✓383noneno CORS headers✕0Failed to fetchhost ignores Range✕0no content-length…Only the wrong source-layer fails without saying so spatialworkflow.io
The step-by-step tests in one table.

Example 3 — checking the host supports what PMTiles needs

def check_hosting(url, origin=None):
    """Range support and CORS headers, which are what actually break."""
    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 ignored'}), "
          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)
        print(f"  preflight {pre.status_code}, allow-origin: {pre.headers.get('access-control-allow-origin') or 'MISSING'}, "
              f"allow-headers: {pre.headers.get('access-control-allow-headers') or 'MISSING'}")
    return ranged.status_code == 206

for label, url in (("local host, /full/", f"{TILES}/full/buildings.pmtiles"),
                   ("local host, /norange/", f"{TILES}/norange/buildings.pmtiles"),
                   ("this site on a Cloudflare Worker", "https://www.spatialworkflow.io/tiles/manchester.pmtiles")):
    print(label)
    check_hosting(url, origin="https://example.org")
local host, /full/
  range request: 206 (ok), content-range: bytes 0-16383/780524
  preflight 204, allow-origin: *, allow-headers: Range
local host, /norange/
  range request: 200 (NOT 206 — ranges ignored), content-range: MISSING
  preflight 204, allow-origin: *, allow-headers: Range
this site on a Cloudflare Worker
  range request: 206 (ok), content-range: bytes 0-16383/2811687
  preflight 405, allow-origin: MISSING, allow-headers: MISSING

The local /norange/ path answered the range request with the whole file and a 200 — exactly the host the browser test refused. This site's own archives come back as 206 from the Cloudflare Worker that serves it, with no CORS: its maps load them from the same origin, so they need none, and another site could not use them without adding the headers.

Why a few requests are enough

Why a few 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, the metadata and the tile data. A client fetches the header and root directory in one read, looks up 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, so subsequent tiles are one request each. The quick answer's map drew its buildings with a handful of range requests and no server process.

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, as step 3 showed. Read the archive's vector_layers metadata rather than guessing.

What CORS actually needs

A cross-origin range request is a preflighted request, so the host must permit the Range header and name an allowed origin. The browser also hides response headers from cross-origin JavaScript unless they are listed in Access-Control-Expose-Headers; the PMTiles client needs Content-Range only in the rare 416 case and ETag only to detect a replaced archive, which is why the test map loaded without them. Expose them anyway — the day an archive is replaced under an open page is the day the ETag matters.

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.

Hosting requirements for serving PMTiles to a browser. what the host has to doreturns 206 for a Range requesta 200 is refusedAccept-Ranges: bytesadvertisedAllow-Origin and Allow-Headers: Rangeif cross-originExpose-Headers: Content-Range, ETagtiny archives, replaced filessensible cache headersarchives are replaced wholeWithout a 206 or without CORS, the PMTiles client loads nothing spatialworkflow.io
The first and third are the ones whose absence the browser test turned into a blank map.

Protocol order, URL prefixes and source layers

  • Register the protocol before creating the map. Straight after new Map() also worked in the test; later did not.
  • pmtiles:// prefixes the real URL — the doubled scheme is correct.
  • source-layer must match the MVT layer name, not the source name.
  • The host must return 206 for range requests; the PMTiles client refuses a 200 with the whole file.
  • Allow the origin and the Range header in CORS, or cross-origin fails with "Failed to fetch" while same-origin works. Expose Content-Range and ETag too; the client uses them in edge cases.
  • A pmtiles:// source takes its zoom range from the archive header; set minzoom/maxzoom only to narrow it.
  • Attach map.on("error", ...); MapLibre is otherwise very quiet.
  • Updating means replacing the archive. Version the URL or set cache headers accordingly.

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 — that draws nothing and reports nothing. Read the archive's vector_layers metadata and use the exact name. If map.on("error") reports something, it is more likely CORS, a host that ignores ranges, or a missing pmtiles:// prefix.

What CORS headers do I need?

Access-Control-Allow-Origin and permission for the Range request header are required. Exposing Content-Range and ETag with Access-Control-Expose-Headers is recommended: the client reads them for tiny archives and to detect a replaced file.

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 — and the PMTiles client refuses that response.

Do I need to set minzoom and maxzoom?

Not with a pmtiles:// URL: the protocol reads the zoom range from the archive header, and MapLibre overzooms the deepest tiles. Set them only to narrow the range, or when you use a plain tiles template.

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.