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 .pmtiles archive; 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=604800 TTL; 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:

Multi-source MapLibre style binding diagram The sources object contains three keys: base-cartography (vector/TileJSON), live-transit (vector/tiles array), and user-annotations (geojson). Arrows cross to the layers array where base-roads references base-cartography, transit-routes references live-transit, and user-markers references user-annotations. sources "base-cartography" type: "vector" url: tilejson.json maxzoom: 16 "live-transit" type: "vector" tiles: ["{z}/{x}/{y}.mvt"] minzoom: 10 "user-annotations" type: "geojson" data: api.example.com/annotations layers "base-roads" source: "base-cartography" source-layer: "transport" "transit-routes" source: "live-transit" source-layer: "routes" "user-markers" source: "user-annotations" (no source-layer — geojson) source key reference

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.

json
{
  "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-layer is mandatory for every vector type layer. Omitting it produces no rendering output and no console error in many MapLibre versions — a silent failure.
  • source-layer must be absent on geojson, raster, and image layers. MapLibre will throw Error: source-layer is not supported on geojson sources.
  • minzoom/maxzoom on sources are hard request boundaries, not rendering hints. MapLibre overzooms gracefully beyond maxzoom using 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+.

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.53.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:

bash
# 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:

bash
tippecanoe \
  --layer=transport \
  --maximum-zoom=16 \
  --drop-densest-as-needed \
  --force \
  --output=base.pmtiles \
  roads.geojson

Up: MapLibre GL JSON Structure