Structuring MapLibre Styles for Multi-Source Tiles
Declare each independent tile endpoint as a distinct key inside the top-level sources object, then bind every rendering layer to one of those keys via the source property — that two-part contract is all MapLibre needs to merge tiles from separate generation pipelines into a single map without coordinate conflicts or cache collisions.
When to Use Multi-Source Style Layouts
Use a multi-source layout when your tile infrastructure is split across more than one origin and those origins are managed, cached, or versioned independently:
- Separate generation pipelines: base cartography built with Tippecanoe CLI fundamentals lives in one
.pmtilesarchive; a real-time transit feed is served from a PostGIS MVT endpoint. - Mixed container formats: one source is a PMTiles archive served via HTTP range requests; another is a live GeoJSON API.
- Independent cache-control windows: base cartography has a
Cache-Control: max-age=604800TTL; live annotations must revalidate on every request. Keeping them in separate source keys lets each follow its own CDN policy without forcing a style-wide cache bust. - Third-party tile overlays: you need to merge your own MVT layers with a hosted street basemap without reprocessing either dataset.
A single-source style with source-layer selectors is sufficient when all data comes from one endpoint. The multi-source pattern adds configuration overhead; adopt it only when the pipelines genuinely diverge.
Specification Detail: sources Object and layers Array
The MapLibre GL Style Specification (v8, current for MapLibre GL JS ≥ 3.x) defines two top-level keys that drive multi-source routing:
| Field | Required | Accepted values | Default |
|---|---|---|---|
sources.<id>.type |
yes | vector, geojson, raster, raster-dem, image |
— |
sources.<id>.url |
one of url/tiles | TileJSON endpoint URL | — |
sources.<id>.tiles |
one of url/tiles | ["{z}/{x}/{y}.mvt"] template array |
— |
sources.<id>.minzoom |
no | 0–22 integer | 0 |
sources.<id>.maxzoom |
no | 0–22 integer | 22 |
sources.<id>.scheme |
no | "xyz", "tms" |
"xyz" |
sources.<id>.attribution |
no | HTML string | — |
sources.<id>.promoteId |
no | attribute name string | — |
layers[n].source |
yes (non-background) | must match a sources key |
— |
layers[n].source-layer |
yes for vector type |
MVT layer name string | — |
layers[n].source-layer |
invalid for geojson |
— | — |
Version requirement: Style specification version must be exactly 8. MapLibre GL JS will reject any other value.
TileJSON vs direct tiles array: Prefer "url" pointing to a TileJSON endpoint when your infrastructure controls authentication, CDN routing, or dynamic bounding boxes — the TileJSON document declares bounds, center, minzoom, and maxzoom internally, so MapLibre can skip out-of-range requests automatically. Use the "tiles" array only for static endpoints where you control the template directly.
The diagram below shows how two vector sources and one GeoJSON source bind to their respective rendering layers:
Production Style JSON
The following template is valid against the MapLibre Style Specification v8. Copy it as a baseline and swap in your own endpoints.
{
"version": 8,
"name": "multi-source-pipeline",
"glyphs": "https://fonts.example.com/glyphs/{fontstack}/{range}.pbf",
"sprite": "https://sprites.example.com/sprite",
"sources": {
"base-cartography": {
"type": "vector",
"url": "https://tiles.example.com/base/tilejson.json",
"maxzoom": 16,
"attribution": "© OpenStreetMap contributors"
},
"live-transit": {
"type": "vector",
"tiles": ["https://cache.example.com/transit/{z}/{x}/{y}.mvt"],
"minzoom": 10,
"maxzoom": 18,
"scheme": "xyz",
"attribution": "Transit Authority"
},
"user-annotations": {
"type": "geojson",
"data": "https://api.example.com/annotations/geojson",
"buffer": 0,
"tolerance": 0.375,
"promoteId": "annotation_id"
}
},
"layers": [
{
"id": "base-roads",
"type": "line",
"source": "base-cartography",
"source-layer": "transport",
"filter": ["==", ["get", "class"], "road"],
"paint": { "line-color": "#444", "line-width": 1.5 }
},
{
"id": "transit-routes",
"type": "line",
"source": "live-transit",
"source-layer": "routes",
"paint": { "line-color": "#0055cc", "line-width": 2 }
},
{
"id": "user-markers",
"type": "circle",
"source": "user-annotations",
"paint": { "circle-radius": 6, "circle-color": "#e63946" }
}
]
}
Critical constraints:
source-layeris mandatory for everyvectortype layer. Omitting it produces no rendering output and no console error in many MapLibre versions — a silent failure.source-layermust be absent ongeojson,raster, andimagelayers. MapLibre will throwError: source-layer is not supported on geojson sources.minzoom/maxzoomon sources are hard request boundaries, not rendering hints. MapLibre overzooms gracefully beyondmaxzoomusing the last available tile, but will not fetch outside the declared range.scheme: "xyz"is the default. Only set it explicitly to"tms"when your tile server uses TMS (south-origin) coordinates.- Expression filters (
["==", ["get", "class"], "road"]) should replace legacy array filters (["==", "class", "road"]) in all new work. Legacy syntax is supported but deprecated in MapLibre GL JS v3+.
Interaction Effects with Related Settings
Multi-source layouts interact directly with three other configuration areas. Each is covered in its own dedicated page; the cross-effects are summarised here.
promoteId and feature state: Adding "promoteId": "annotation_id" to a source enables map.setFeatureState() against stable feature identifiers rather than volatile tile-internal indices. This is essential when binding data-driven properties to vector layers such as hover highlights or selection rings across layers from different sources. Without promoteId, feature state silently breaks across tile reloads.
Attribute filtering and payload size: If any source is generated via Tippecanoe, dropping unused attributes during tile generation is the fastest way to reduce the bytes MapLibre must parse per tile. Attributes referenced inside ["get", ...] expressions in the layers array must be explicitly retained via -y / --include flags at generation time, or the expression will return null at render time.
Layer ordering and z-index: MapLibre renders layers array entries in order: index 0 is drawn first (bottom), the last index is drawn on top. There is no z-index property. Place base-cartography layers first, thematic overlays second, and interactive annotation layers last. When combining sources that cover overlapping geographic extents, wrong layer order is the most common cause of features appearing hidden.
Dynamic attribute mapping: For styling layers dynamically from tile properties, the full dynamic attribute mapping workflow describes how to keep attribute keys consistent from generation through to the MapLibre expression evaluator.
Performance Impact
Each active source generates a separate series of HTTP tile requests per viewport position. With three sources and a 3×3 visible tile grid, MapLibre fires up to 27 concurrent requests on zoom change. Tune these parameters to reduce unnecessary fetches:
| Parameter | Location | Effect |
|---|---|---|
maxzoom on source |
sources.<id> |
Prevents requests beyond your highest generated zoom. MapLibre overzooms the last tile. |
minzoom on source |
sources.<id> |
Stops fetching sparse data (transit, POIs) at low zooms where it adds no visual value. |
buffer on geojson |
sources.<id> |
Set to 0 for point data; 1 or 2 for lines and polygons that cross tile boundaries. |
TileJSON url vs tiles |
sources.<id> |
TileJSON lets MapLibre read bounds and skip out-of-extent requests entirely. |
tolerance on geojson |
sources.<id> |
Douglas-Peucker simplification coefficient. 0.375 is the Mapbox default; raise it to 1.5–3.0 for large polygons at low zoom. |
For base cartography tiles, MBTiles architecture limits apply if you are serving from SQLite. The 500 KB per-tile budget governs how densely you can pack attributes before the tile server struggles under concurrent reads.
At production scale with multi-source styles, the dominant cost is usually network latency for the source that has the highest maxzoom. Profile with the browser’s Network panel: filter by .mvt or .pbf to isolate tile requests per source key and identify which source is the bottleneck.
Common Mistakes
1. Missing source-layer on a vector source
Symptom: layer renders nothing; no error in the console. Add a source-layer value matching an actual layer name in the MVT. Verify the layer names in your tiles:
# Inspect layer names inside an .mbtiles archive
tippecanoe-decode --statistics output.mbtiles | jq '.tilestats.layers[].layer'
# Or for a single .mvt tile
node -e "
const {VectorTile} = require('@mapbox/vector-tile');
const Pbf = require('pbf');
const fs = require('fs');
const tile = new VectorTile(new Pbf(fs.readFileSync('14-8192-5432.mvt')));
console.log(Object.keys(tile.layers));
"
2. Duplicate source endpoints under different keys
Symptom: network waterfall shows the same tile URL requested twice per viewport update; double CDN bandwidth cost. MapLibre treats each source key as an independent fetch pipeline. If base-roads and base-labels reference different keys but the same tile URL, you pay twice. Merge them under one source key and use source-layer to route each layer to the correct MVT layer name.
3. source-layer value does not match the MVT layer name exactly
Symptom: layer is invisible; map.getSource('base-cartography').loaded() returns true. MVT layer names are case-sensitive. A tile generated with --layer=Transport will not match "source-layer": "transport". Standardise layer names to lowercase during generation:
tippecanoe \
--layer=transport \
--maximum-zoom=16 \
--drop-densest-as-needed \
--force \
--output=base.pmtiles \
roads.geojson
Up: MapLibre GL JSON Structure
Related
- Dynamic Attribute Mapping — how to bind tile attributes to paint properties using MapLibre expressions, with attribute retention patterns for Tippecanoe and PostGIS pipelines.
- Binding Data-Driven Properties to Vector Layers — expression syntax for
match,interpolate, andcaseagainst feature attributes embedded during tile generation. - Dropping Unused Attributes to Reduce Tile Size — configure
-y/--includeflags so only the attributes referenced in yourlayersexpressions survive tiling. - MBTiles Architecture Limits — SQLite concurrency constraints that affect multi-source pipelines reading from local
.mbtilesfiles under high request load.