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 .pmtiles or MBTiles SQLite container — the source-layer field in every vector layer must match them character-for-character.
  • Glyph & sprite assets: pre-compiled .pbf font stacks and .json/.png sprite sheets deployed to HTTPS endpoints with Access-Control-Allow-Origin: * headers.
  • Validation tooling: @maplibre/maplibre-gl-style-spec (Node.js) or jsonschema (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:

json
{
  "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:

  1. Draw order is array order — background and fill layers must come before line and symbol layers or they will paint over each other.
  2. source-layer is 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:

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

MapLibre GL style JSON component relationships The style JSON object contains sources and layers. Sources reference tile endpoints. Each layer references a source and a source-layer name. The renderer draws layers in array order onto the WebGL canvas. style.json sources type: "vector" tiles: ["{z}/{x}/{y}"] minzoom / maxzoom bounds layers[ ] id / type / source source-layer (PBF name) filter / paint / layout minzoom / maxzoom tile URL draw order Renderer Fetches tiles Decodes PBF layers Evaluates expressions Applies paint/layout Loads glyphs Loads sprites Draws to WebGL canvas WebGL canvas

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:

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

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

javascript
// 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:

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

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

javascript
// 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:

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

javascript
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:

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

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

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


Next reading Structuring MapLibre Styles for Multi-Source Tiles