Writing Valid TileJSON for MapLibre Sources

MapLibre needs four things from a TileJSON document — a tiles array, a zoom range, and vector_layers — and it will load a source that has nothing else. Everything beyond that minimum is either for other consumers or for humans, and knowing which is which stops a lot of pointless debugging.

When to Use This

Write a TileJSON document when the tiles are served from static storage under a z/x/y template and something other than the PMTiles client has to read them. A tile server generates its own; a PMTiles archive carries its metadata internally and the protocol handler reads it directly. The gap is the static case: a directory of .mvt files, or a tileset consumed by a tool that speaks TileJSON and nothing else.

Specification Detail: What MapLibre Actually Reads

Field Required What MapLibre does with it
tiles Yes The URL template it substitutes {z}, {x} and {y} into
minzoom No, defaults to 0 The shallowest zoom it will request
maxzoom No, defaults to 22 The deepest zoom it requests; above this it overzooms
vector_layers Effectively yes Resolves each style layer’s source-layer
bounds No Restricts which tiles it bothers requesting
attribution No Rendered in the attribution control
scheme No, defaults to xyz Set to tms if y counts north
tilejson, name, description, version No Ignored entirely by the renderer

The two defaults in that table cause most of the surprises. A document omitting maxzoom tells MapLibre to request tiles up to z22, and a tileset built to z14 answers eight zoom levels of requests with 404s. A document omitting minzoom is usually harmless, since z0 tiles are cheap, but on a city-scale tileset it produces a burst of 404s at world view.

The four fields a MapLibre vector source cannot do withoutA minimal TileJSON document reduced to its tiles template, zoom range and vector_layers array, with everything else marked optional.THE MINIMUMtiles[]requiredthe {z}/{x}/{y} templateminzoomdefaults to 0404s below the builtrangemaxzoomdefaults to 22404s above it — alwaysset thisvector_layers[]required in practiceresolves everysource-layer
Omitting maxzoom is the expensive default: MapLibre will request up to z22 and receive 404s for every level the build never produced.

Production Document

A complete, minimal document for a static tileset:

json
{
  "tilejson": "3.0.0",
  "name": "basemap-v43",
  "tiles": ["https://tiles.example.com/v43/basemap/{z}/{x}/{y}.mvt"],
  "minzoom": 0,
  "maxzoom": 14,
  "bounds": [-0.51, 51.28, 0.33, 51.69],
  "center": [-0.09, 51.49, 11],
  "attribution": "© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors",
  "vector_layers": [
    {
      "id": "roads",
      "minzoom": 5,
      "maxzoom": 14,
      "fields": { "name": "String", "highway": "String", "lanes": "Number" }
    },
    {
      "id": "buildings",
      "minzoom": 13,
      "maxzoom": 14,
      "fields": { "height": "Number" }
    }
  ]
}

And the style that consumes it:

javascript
map.addSource("basemap", {
  type: "vector",
  url: "https://tiles.example.com/v43/basemap.json"
});

map.addLayer({
  id: "roads-line",
  type: "line",
  source: "basemap",
  "source-layer": "roads",     // must equal a vector_layers[].id
  minzoom: 5,
  paint: { "line-color": "#888", "line-width": 1.2 }
});

Interaction Effects

Inline tiles versus a url. A source can skip TileJSON entirely by listing tiles directly in the style. That works, and it costs you vector_layers — which means nothing validates source-layer against the tileset, and the schema fixture a style validation workflow needs has nowhere to come from. Inline is fine for a prototype and a liability in a pipeline.

Per-layer zoom ranges versus the source range. The minzoom inside a vector_layers entry is documentation; MapLibre does not use it to skip requests, because a tile may contain several layers with different ranges. To actually suppress requests, set the range on the source, and set the per-style-layer minzoom to control drawing.

Relative URLs. The tiles template may be relative to the TileJSON document’s own URL. This is genuinely useful for a versioned prefix — "tiles": ["{z}/{x}/{y}.mvt"] inside /v43/basemap.json resolves under /v43/ — and it means rotating the prefix requires no rewriting of the template at all.

What MapLibre fetches when a source names a TileJSON URLThe style is parsed, the TileJSON document is fetched, its tiles template and zoom range configure the source, and only then are tile requests issued.MapLibreTileJSON urlTile originGET basemap.jsontiles template, zoom range, vector_layersresolve every source-layer against vector_layersGET 12/2048/1362.mvtonly now does a tile move
The TileJSON fetch is on the critical path for the first tile. Give it the same cache headers as the tiles or it becomes a round trip on every page load.

Performance Impact

The document itself is a kilobyte or two and is fetched once per source per page load. Its cost is not size but position: it sits between the style and the first tile, so its latency is added directly to the time before anything is drawn. Two consequences follow.

Serve it from the same origin as the tiles, with the same immutable cache headers, under the same versioned prefix. A TileJSON document on a slow origin makes a fast tile origin irrelevant.

And prefer one source with several layers to several sources with one layer each. Each source is its own TileJSON fetch, and four sources means four sequential-ish round trips before a complete map appears — the multi-source structuring guide covers when the separation is nonetheless worth it.

What the Extra Fields Are For

It is tempting, having learned that MapLibre ignores tilejson, name, description and version, to drop them. Keep them, for two reasons that have nothing to do with the renderer.

The first is other consumers. QGIS, tippecanoe-adjacent tooling, felt.com-style viewers and internal dashboards all read TileJSON, and several of them use name and description as the label a user sees when picking a tileset. A document that renders correctly in MapLibre and shows up as “Untitled” everywhere else is a small, avoidable annoyance.

The second is provenance. A version field carrying the build’s version prefix, and a free-form metadata object carrying the commit that produced the tiles, turns a rendered map into something traceable. When someone asks why a road looks wrong, the answer starts by reading the version out of the TileJSON the map actually loaded rather than guessing which build is live.

Both are cheap. The document is generated, so populating four extra fields costs nothing per build and answers questions that are otherwise genuinely hard.

Validating the Document

Two checks cover essentially all of the failure modes, and both are short enough to live in a build script:

bash
# 1. Structural: required fields present, zoom range sane
jq -e '
  (.tiles | type == "array" and length > 0)
  and (.maxzoom | type == "number")
  and (.minzoom <= .maxzoom)
  and (.vector_layers | type == "array" and length > 0)
' basemap.json > /dev/null && echo "structure ok"

# 2. Live: the template resolves to a real tile at the declared max zoom
TILE=$(jq -r '.tiles[0]' basemap.json | sed 's/{z}/14/;s/{x}/8188/;s/{y}/5449/')
curl -sfI "$TILE" | head -1

The second check is the one worth insisting on. A structurally perfect document with a typo in its URL template passes every schema validator ever written and produces a completely blank map.

Which fields MapLibre reads, and which exist for everyone elseSeven TileJSON fields classified by whether MapLibre uses them, what happens when each is omitted, and which other consumers depend on them.WHO READS WHATMapLibre uses itomitted meanstilesYesNo source at allmaxzoomYesRequests up to z22 —404svector_layersYesNo source-layerresolvesboundsYesfitBounds has nothingattributionYesA licence obligationunmetname, descriptionNoUntitled in other toolsversionNoNo provenance to trace
The fields MapLibre ignores are not decoration — they are what makes the tileset legible to every other tool that reads TileJSON.

Common Mistakes

Omitting maxzoom and blaming the tile server for 404s. The symptom is a burst of 404s that appears only when a reader zooms past the built range. curl one of the missing URLs and you will find the tileset never had that level.

bash
curl -sI "https://tiles.example.com/v43/basemap/16/32768/21845.mvt" | head -1
# HTTP/2 404   <- correct: the archive was built to z14

A vector_layers entry whose id does not match the tile’s layer name. The document is valid, the tiles are fine, and the map is empty. Compare the two directly rather than reading the build script:

bash
jq -r '.vector_layers[].id' basemap.json | sort > /tmp/declared
pmtiles tile basemap.pmtiles 12 2048 1362 | gzip -d \
  | npx @mapbox/vector-tile-cli list | sort > /tmp/actual
diff /tmp/declared /tmp/actual

Serving it without CORS. The tiles are cross-origin and configured; the TileJSON document is a different content type on the same bucket and often misses the policy. The failure is a CORS error naming the JSON URL, before a single tile is requested.

FAQ

What is the minimum MapLibre will accept?

A tiles array and vector_layers. It will load a source with nothing else, and it will then request tiles up to z22 because maxzoom defaults there.

Which fields does MapLibre ignore?

tilejson, name, description and version. Keep them anyway — other consumers use them as labels, and version is what makes a rendered map traceable to a build.

Can the tiles template be relative?

Yes, and it is genuinely useful. A template of {z}/{x}/{y}.mvt inside /v43/basemap.json resolves under that prefix, so rotating the version needs no rewriting.

What is the one check worth insisting on?

That the template resolves to a real tile. A structurally perfect document with a typo in its URL passes every schema validator and produces a blank map.