Every serious web mapping project eventually hits the same question: where do the tiles come from? Raster tiles from OpenStreetMap are fine for base maps, but when you need to style your own data — choropleth polygons, point clusters, route lines — you need a tile source you control.
The traditional answer was a tile server: a running service (TileServer GL, Martin, tegola) that reads from PostGIS or MBTiles files and generates tiles on demand. That works, but it means managing infrastructure: a VM, a database, a server process, monitoring, scaling.
PMTiles offers a different model entirely. It is a single binary file that contains all your tiles, indexed so that HTTP range requests can fetch any individual tile without reading the whole file. Put it on S3 or GitHub Pages. Done. No server.
How PMTiles Works
A PMTiles archive is a file with a B-tree index in its header. The index maps (z, x, y) tile coordinates to byte offsets within the file. When MapLibre (or any PMTiles-aware client) needs tile (12, 2048, 1365), it:
- Makes a range request for the index header
- Looks up the byte range for that tile
- Makes a second range request for just those bytes
Two HTTP requests per tile, zero backend computation. S3, Cloudflare R2, GitHub Pages, and any CDN that supports Accept-Ranges work as the “server”.
Generating PMTiles from Your Data
Tippecanoe is the standard tool for generating vector tile archives from GeoJSON, Newline-Delimited GeoJSON (NDJSON), or other sources.
# Install on macOS
brew install tippecanoe
# On Linux
git clone https://github.com/felt/tippecanoe.git
cd tippecanoe && make && sudo make install
From GeoJSON
# Convert GeoJSON to PMTiles
tippecanoe \
--output=output-areas.pmtiles \
--minimum-zoom=6 \
--maximum-zoom=14 \
--layer=output_areas \
--simplification=4 \
--drop-densest-as-needed \
output_areas_2021.geojson
Key options:
--minimum-zoom/--maximum-zoom: tile pyramid range. For polygon data, z6–z14 covers national-to-neighbourhood scale--simplification: simplify geometries (4 = moderate). Lower is more accurate, higher is smaller files--drop-densest-as-needed: at low zoom levels, drop features to meet tile size limits (important for point datasets)--layer: the layer name you will reference in MapLibre style
From Multiple Layers
Combine multiple datasets into one PMTiles archive with joined layer names:
tippecanoe \
--output=uk-geodata.pmtiles \
--maximum-zoom=14 \
--layer=lsoas output/lsoas.geojson \
--layer=roads output/roads.geojson \
--layer=points_of_interest output/pois.geojson
From GeoPackage via GDAL
Convert to NDJSON first using ogr2ogr, then pass to Tippecanoe:
ogr2ogr -f GeoJSONSeq output_areas.ndjson output_areas.gpkg output_areas_layer
tippecanoe --output=output-areas.pmtiles output_areas.ndjson
Inspecting the Archive
Once generated, inspect the PMTiles metadata:
# Via the pmtiles CLI (install: npm install -g pmtiles)
pmtiles show output-areas.pmtiles
pmtiles verify output-areas.pmtiles
Output shows: total tiles, zoom range, bounding box, centre, format (pbf/mvt), and layer names. The layer names here are what you will use in your MapLibre style.
Hosting
# Upload to S3 with public read access and correct content type
aws s3 cp output-areas.pmtiles s3://my-bucket/tiles/ \
--content-type application/vnd.pmtiles \
--acl public-read
# Enable CORS on the bucket (required for browser requests)
aws s3api put-bucket-cors --bucket my-bucket --cors-configuration '{
"CORSRules": [{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range"],
"ExposeHeaders": ["Content-Range", "Content-Length", "Accept-Ranges"],
"MaxAgeSeconds": 3600
}]
}'
The Range header in AllowedHeaders is essential — without it, the browser’s range requests will be blocked by CORS.
For development you can also serve locally:
pmtiles serve . --port 8080
# Tiles available at: http://localhost:8080/output-areas.pmtiles/{z}/{x}/{y}
Building the Map with MapLibre GL JS
MapLibre GL JS supports PMTiles natively via the pmtiles JavaScript package:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>UK Output Areas</title>
<link href="https://unpkg.com/maplibre-gl/dist/maplibre-gl.css" rel="stylesheet" />
<style>
body { margin: 0; padding: 0; }
#map { width: 100vw; height: 100vh; }
#controls {
position: absolute;
top: 16px; right: 16px;
background: rgba(15, 15, 20, 0.85);
color: #e0e0e0;
padding: 12px 16px;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="controls">
<label>
Opacity:
<input type="range" id="opacity-slider" min="0" max="1" step="0.05" value="0.7" />
</label>
</div>
<script src="https://unpkg.com/maplibre-gl/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/pmtiles@3/dist/pmtiles.js"></script>
<script>
// Register PMTiles protocol with MapLibre
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile.bind(protocol));
const PMTILES_URL = "https://my-bucket.s3.eu-west-2.amazonaws.com/tiles/output-areas.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"
},
"output-areas": {
type: "vector",
url: `pmtiles://${PMTILES_URL}`
}
},
layers: [
{
id: "basemap-tiles",
type: "raster",
source: "basemap"
},
{
id: "oa-fill",
type: "fill",
source: "output-areas",
"source-layer": "output_areas", // matches --layer name in Tippecanoe
paint: {
"fill-color": [
"interpolate",
["linear"],
["get", "population_density"],
0, "#f7fbff",
1000, "#6baed6",
5000, "#08306b"
],
"fill-opacity": 0.7,
"fill-outline-color": "rgba(100, 100, 150, 0.3)"
}
},
{
id: "oa-hover",
type: "fill",
source: "output-areas",
"source-layer": "output_areas",
paint: {
"fill-color": "#ffcc00",
"fill-opacity": 0.5
},
filter: ["==", ["get", "OA21CD"], ""] // initially empty filter
}
]
},
center: [-1.15, 52.95],
zoom: 10
});
// Hover interaction
let hoveredId = null;
map.on("mousemove", "oa-fill", (e) => {
if (e.features.length > 0) {
const code = e.features[0].properties.OA21CD;
map.setFilter("oa-hover", ["==", ["get", "OA21CD"], code]);
map.getCanvas().style.cursor = "pointer";
}
});
map.on("mouseleave", "oa-fill", () => {
map.setFilter("oa-hover", ["==", ["get", "OA21CD"], ""]);
map.getCanvas().style.cursor = "";
});
// Click popup
map.on("click", "oa-fill", (e) => {
const props = e.features[0].properties;
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(`
<strong>${props.OA21CD}</strong><br/>
Population: ${props.population?.toLocaleString() ?? "N/A"}<br/>
Area: ${(props.area_ha ?? 0).toFixed(1)} ha
`)
.addTo(map);
});
// Opacity slider
document.getElementById("opacity-slider").addEventListener("input", (e) => {
map.setPaintProperty("oa-fill", "fill-opacity", parseFloat(e.target.value));
});
</script>
</body>
</html>
Performance at Scale
PMTiles file sizes for common UK datasets:
| Dataset | Features | PMTiles size | Load time (z10, UK extent) |
|---|---|---|---|
| Output Areas 2021 | 188,880 | ~45 MB | ~80ms |
| LSOAs 2021 | 35,672 | ~18 MB | ~30ms |
| UK Road Network (OS Open Roads) | 3.8M | ~380 MB | ~110ms |
“Load time” here is the time to fetch the index + the tiles covering the initial viewport — not the full file. A 380 MB roads archive still delivers visible tiles in under 150ms because only the tiles covering the screen are ever fetched.
Why This Matters for Production
The PMTiles approach shifts infrastructure costs dramatically:
- No tile server to run: a static file on S3 Requester Pays or Cloudflare R2 costs fractions of a penny per GB transferred
- No cold start: CDN-cached range requests are fast with no server warm-up
- Trivial versioning: regenerate the
.pmtilesfile, upload with a version suffix, update the URL in your frontend — done
For research applications, the model is even more appealing: publish a .pmtiles file alongside your GeoJSON data release. Anyone with a browser can explore your spatial dataset interactively, without you running any backend.
Further Resources
- PMTiles specification and CLI
- MapLibre GL JS documentation
- Tippecanoe documentation
- Protomaps basemap — a free PMTiles world basemap
- Felt’s styled layers editor — visual layer styling for MapLibre