MapLibre GL JSON Structure
The MapLibre GL style JSON is the declarative contract that maps vector tile sources onto rendered layers. Getting it wrong — mismatched source-layer names, bad expression syntax, or missing glyph endpoints — produces silent rendering failures that are painful to diagnose in production. This page covers the full anatomy of the style spec, how to generate and validate it inside a CI/CD pipeline, and the failure modes most likely to surface when the style is built programmatically rather than hand-edited.
Prerequisites
Before automating style generation you need:
- Tile source alignment: know the exact layer names embedded in your
.pmtilesor MBTiles SQLite container — thesource-layerfield in every vector layer must match them character-for-character. - Glyph & sprite assets: pre-compiled
.pbffont stacks and.json/.pngsprite sheets deployed to HTTPS endpoints withAccess-Control-Allow-Origin: *headers. - Validation tooling:
@maplibre/maplibre-gl-style-spec(Node.js) orjsonschema(Python 3.10+) configured in your CI environment. - Object storage: an S3-compatible bucket, Cloudflare R2, or CDN origin where versioned style blobs are uploaded.
Treat the deployed style JSON as an immutable artifact. Any change flows through a new build and a new hash-based filename — not an in-place edit — so rollback is a pointer swap, not a re-deployment.
Core Concept: Style Spec Anatomy
A MapLibre GL style document has seven top-level keys. The table below lists each one, its type, and the pipeline implications that matter when the file is generated programmatically.
| Key | Type | Required | Pipeline notes |
|---|---|---|---|
version |
integer | yes | Must be 8; hardcode it in every generator template |
name |
string | no | Use as a cache key and telemetry tag; inject from build env |
sources |
object | yes | Each source must declare type, tiles or url, minzoom, maxzoom, and bounds |
layers |
array | yes | Ordered; renderer draws from index 0 upward; source-layer must match the tile’s PBF layer name exactly |
glyphs |
string | no* | Required if any layer uses type: "symbol" with text; must contain {fontstack}/{range} placeholders |
sprite |
string | no* | Required for icon symbols; resolves to a .json + .png pair at the same base URL |
metadata |
object | no | Free-form; use for build timestamps, commit SHA, and theme variant tags |
*Omitting glyphs or sprite when layers reference them causes a silent load error, not a spec validation failure.
Source definition
Every vector source needs explicit zoom bounds and a bounding box so the renderer can skip tile requests outside its coverage area:
{
"sources": {
"urban-parcels": {
"type": "vector",
"tiles": ["https://tiles.example.com/urban-parcels/{z}/{x}/{y}.pbf"],
"minzoom": 8,
"maxzoom": 14,
"bounds": [-180, -85.051129, 180, 85.051129],
"attribution": "© OpenStreetMap contributors"
}
}
}
Omitting bounds causes the renderer to request tiles globally at every zoom level, generating 404s for tiles outside your dataset extent and inflating CDN egress costs.
Layer ordering and source-layer matching
The layers array is the most fragile part of a generated style. Two rules govern it:
- Draw order is array order — background and fill layers must come before line and symbol layers or they will paint over each other.
source-layeris case-sensitive and must match the PBF layer name byte-for-byte — a layer named"road_network"in the tile will not render if you reference"Road_Network"in the style.
To discover the actual layer names in a tile, inspect the .pmtiles or .mbtiles file with pmtiles show or the tile-join --info flag from the Tippecanoe CLI:
# List PBF layer names from a PMTiles archive
pmtiles show output.pmtiles
# List layer names from an MBTiles file via tile-join
tile-join --info output.mbtiles
SVG: Style JSON component relationships
Step-by-Step Implementation
Step 1 — Generate the style from a template
Never hand-edit environment-specific URLs into a style file. Use a template engine to inject them at build time so that the same template works across dev, staging, and production:
# generate_style.py — Jinja2-based style generator
import json
import hashlib
import os
from jinja2 import Template
from datetime import datetime, timezone
TEMPLATE = """{
"version": 8,
"name": "{{ name }}",
"glyphs": "{{ glyph_base_url }}/{fontstack}/{range}.pbf",
"sprite": "{{ sprite_base_url }}/sprites/default",
"sources": {
"parcels": {
"type": "vector",
"tiles": ["{{ tile_base_url }}/parcels/{z}/{x}/{y}.pbf"],
"minzoom": 8,
"maxzoom": 14,
"bounds": [-180, -85.051129, 180, 85.051129]
}
},
"layers": {{ layers | tojson(indent=2) }},
"metadata": {
"build_timestamp": "{{ build_ts }}",
"tileset_commit": "{{ tileset_commit }}",
"pipeline_version": "{{ pipeline_version }}"
}
}"""
def generate_style(layers: list, env: dict) -> tuple[str, str]:
"""Returns (rendered_json_str, sha256_hex)."""
rendered = Template(TEMPLATE).render(
name=env["STYLE_NAME"],
glyph_base_url=env["GLYPH_BASE_URL"],
sprite_base_url=env["SPRITE_BASE_URL"],
tile_base_url=env["TILE_BASE_URL"],
layers=layers,
build_ts=datetime.now(timezone.utc).isoformat(),
tileset_commit=env.get("TILESET_COMMIT", "unknown"),
pipeline_version=env.get("PIPELINE_VERSION", "0.0.0"),
)
sha = hashlib.sha256(rendered.encode()).hexdigest()[:16]
return rendered, sha
Verify: json.loads(rendered) must not raise — any Jinja syntax error or missing variable will produce invalid JSON.
Step 2 — Validate the generated style
Schema validation catches deprecated properties and type errors. Expression validation catches runtime errors before deployment. Run both:
# validate_style.py
import json
import jsonschema
from pathlib import Path
# Cache this locally — do not fetch from GitHub on every CI run
SCHEMA_PATH = Path("ci/maplibre-style-spec-v8.json")
def validate_style(style_str: str) -> list[str]:
"""Returns list of error messages; empty list means valid."""
style = json.loads(style_str)
schema = json.loads(SCHEMA_PATH.read_text())
errors = []
validator = jsonschema.Draft7Validator(schema)
for err in validator.iter_errors(style):
path = " > ".join(str(p) for p in err.absolute_path)
errors.append(f"{path}: {err.message}")
return errors
For expression-level validation, use the official Node.js package:
// validate-expressions.mjs
import { createExpression, validateStyle } from '@maplibre/maplibre-gl-style-spec';
import { readFileSync } from 'fs';
const style = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const errors = validateStyle(style);
if (errors.length > 0) {
console.error(JSON.stringify(errors, null, 2));
process.exit(1);
}
Verify: the validator exits 0 and prints no errors.
Step 3 — Check asset availability before deployment
A valid style JSON can still fail at load time if the glyph or sprite endpoints are unreachable. Add a pre-flight check to CI:
#!/usr/bin/env bash
# preflight-assets.sh — run before uploading style to CDN
set -euo pipefail
GLYPH_URL="https://cdn.example.com/fonts/Roboto%20Regular/0-255.pbf"
SPRITE_JSON="https://cdn.example.com/sprites/default.json"
SPRITE_PNG="https://cdn.example.com/sprites/default.png"
for url in "$GLYPH_URL" "$SPRITE_JSON" "$SPRITE_PNG"; do
status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
if [[ "$status" != "200" ]]; then
echo "FAIL: $url returned $status" >&2
exit 1
fi
done
echo "All assets reachable"
Verify: script exits 0 for all three URLs.
Step 4 — Upload to object storage with a hash-based filename
Upload the style as an immutable versioned blob, then update a pointer file separately:
#!/usr/bin/env bash
# deploy-style.sh
STYLE_FILE="$1" # e.g. style-a1b2c3d4e5f6.json
BUCKET="s3://tiles.example.com/styles"
# Upload immutable versioned file
aws s3 cp "$STYLE_FILE" "$BUCKET/$STYLE_FILE" \
--cache-control "public, max-age=31536000, immutable" \
--content-type "application/json"
# Update the mutable pointer — short TTL so clients pick up changes quickly
printf '{"current":"%s"}' "$STYLE_FILE" > pointer.json
aws s3 cp pointer.json "$BUCKET/pointer.json" \
--cache-control "no-cache" \
--content-type "application/json"
Verify: aws s3 ls s3://tiles.example.com/styles/ shows the new hash-named file and an updated pointer.json timestamp.
Step 5 — Wire the style into the application
The client loads pointer.json first, resolves the current style URL, then fetches the immutable blob. This pattern gives you zero-downtime deployments and instant rollback by swapping pointer.json:
// load-style.js — fetch pointer then immutable style blob
async function loadCurrentStyle(pointerUrl) {
const pointer = await fetch(pointerUrl).then(r => r.json());
const styleUrl = `https://cdn.example.com/styles/${pointer.current}`;
return fetch(styleUrl).then(r => r.json());
}
const map = new maplibregl.Map({
container: 'map',
style: await loadCurrentStyle('https://cdn.example.com/styles/pointer.json'),
});
Optimization Knobs
| Parameter | Low setting | High setting | Trade-off |
|---|---|---|---|
Layer minzoom / maxzoom |
Narrow range (e.g. 10–14) | Broad range (0–22) | Narrower = fewer draw calls at irrelevant zooms; broader = more CPU/GPU per frame at low zoom |
| Expression complexity | Simple ["get", "class"] lookups |
Nested ["case"] / ["interpolate"] chains |
Complex expressions evaluate per-feature per-frame; use ["match"] over ["case"] where possible |
| Layer count | Minimal (10–20 layers) | Verbose (100+ layers) | Every layer is a GPU draw call; consolidate same-source layers with filters rather than duplicating |
For dynamic attribute mapping where paint properties change at runtime, prefer data-driven expressions baked into the style at build time over calling map.setPaintProperty() in a loop — the former is evaluated in the render thread, the latter triggers synchronous style recompilation.
Integration with Adjacent Pipeline Stages
The style JSON sits at the boundary between tile generation and client-side rendering. Data flows in both directions:
Upstream (tile generation → style)
Your Tippecanoe build determines which layer names and attribute keys appear in the PBF. Any attribute filtering that drops a property from the tile will break a ["get", "property_name"] expression in the style. The safe practice is to derive the source-layer list and available attribute names from the tile spec before running the style generator.
Downstream (style → CDN)
The map styling and layer synchronization delivery chain requires the CDN to serve the style blob with correct Cache-Control headers. The immutable blob gets max-age=31536000 so clients never re-fetch it; the pointer gets no-cache so a new pointer is always re-validated. When you rotate tile sources (e.g. after a geometry update via GeoParquet input processing), deploy the new style blob first and only then update the tile server — never in reverse, which would leave a window where the old style references non-existent tile layers.
For multi-source tile configurations, where the style merges a vector basemap with raster overlays or additional feature sets from separate tile endpoints, each source needs its own zoom-level constraints to prevent unnecessary tile requests.
Troubleshooting
Problem: Map renders blank — no features visible
Diagnosis: open the browser console. A source-layer mismatch produces no error; the layer simply draws nothing. Run:
# Confirm actual PBF layer names in your PMTiles archive
pmtiles show output.pmtiles | grep "Layer:"
# Or for MBTiles:
sqlite3 output.mbtiles "SELECT name FROM tiles LIMIT 0;" 2>/dev/null
tile-join --info output.mbtiles
Fix: update every "source-layer" value in the style to match the output of the above command exactly.
Problem: Error: style is not done loading on map.setStyle()
Cause: calling map.setStyle() before the map’s initial style has emitted the load event, or calling it concurrently from two async paths.
Fix:
map.once('load', () => {
map.setStyle(newStyleUrl);
});
Problem: Text labels missing, no font errors in console
Cause: the glyphs URL is correct but a specific font in the font stack is absent from the glyph server. The renderer silently falls back and renders nothing.
Diagnosis:
# Check that the specific font variant exists — not just the base font
curl -I "https://cdn.example.com/fonts/Roboto%20Bold/0-255.pbf"
# 404 means the font variant is missing
Fix: either add the missing font to the glyph server, or change the layer’s text-font to a stack that the server actually hosts.
Problem: ValidationError: layers[12].paint.fill-color: "expression" is not a valid type
Cause: an expression that uses a newer spec operator (e.g. ["config"], ["random"]) against an older version of maplibre-gl-style-spec.
Fix: pin the spec package version in both the validator and the renderer to the same minor version:
npm install @maplibre/maplibre-gl-style-spec@^0.3.0 maplibre-gl@^4.0.0
Problem: Sprite icons not rendering, no HTTP errors
Cause: the sprite base URL resolves but returns the wrong MIME type, or the .json and .png files have mismatched names (e.g. [email protected] present but default.json absent).
Diagnosis:
curl -I "https://cdn.example.com/sprites/default.json"
# Check Content-Type: must be application/json, not text/html (which means a 404 redirect)
Fix: confirm both default.json and default.png (non-retina) exist alongside the @2x variants. The renderer requests both.
Child Pages
Two in-depth pages extend the concepts covered here:
- Structuring MapLibre Styles for Multi-Source Tiles — how to merge vector basemaps, raster overlays, and independently versioned tile endpoints into a single style without z-index conflicts or projection mismatches.
Related
- Map Styling & Layer Synchronization — parent overview covering the full style-to-CDN delivery chain.
- Dynamic Attribute Mapping — binding tile feature properties to paint and layout expressions at build time and runtime.
- Theme Inheritance Patterns — base style + delta override strategy for managing multiple regional or branded map variants from a single template.
- Style Validation Workflows — CI/CD gates for catching expression errors, missing assets, and spec regressions before deployment.
- Tippecanoe CLI Fundamentals — the upstream tile generation step whose layer names and attribute keys the style JSON depends on.