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.
Production Document
A complete, minimal document for a static tileset:
{
"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:
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.
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:
# 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.
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.
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:
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.
Related
- Tile Metadata & TileJSON — the parent topic: where each field comes from and how to generate the document from the archive.
- Fixing Missing vector_layers in MBTiles Metadata — what to do when the array this page depends on is not there.
- Structuring MapLibre Styles for Multi-Source Tiles — when a second source earns its extra round trip.
- Overzooming vs Generating Higher Max-Zoom Tiles — what
maxzoomactually triggers on the client.