Skip to main content
/ 6 min read

Self-Hosted Vector Tiles with PMTiles and MapLibre GL JS

Serve a fully interactive web map from a single static file — no tile server, no backend, no infrastructure

Featured image for Self-Hosted Vector Tiles with PMTiles and MapLibre GL JS - Serve a fully interactive web map from a single static file — no tile server, no backend, no infrastructure

"Vector tiles have become the standard for interactive web mapping, but running a tile server has always been the barrier to entry. PMTiles changes this: it is a single-file archive format that lets browsers fetch individual tiles via HTTP range requests, turning any static host — S3, GitHub Pages, a CDN — into a tile server."

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:

  1. Makes a range request for the index header
  2. Looks up the byte range for that tile
  3. 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:

DatasetFeaturesPMTiles sizeLoad time (z10, UK extent)
Output Areas 2021188,880~45 MB~80ms
LSOAs 202135,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 .pmtiles file, 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

James Williams
Dr James Williams
Research Fellow

Researching the intersection of place, maps, and technology.

More about me →

Posts on this blog are refined using AI. All ideas, research, and technical content originate with the author; AI assists with drafting and editing.