Cascading Theme Overrides for Multi-Tenant Web Maps
Cascading theme overrides for multi-tenant web maps resolve each tenant’s map style as a shared base style deep-merged with an ordered chain of override patches — base, then brand, then tenant — with design tokens resolved last and the merged result validated against a schema so a malformed override cannot ship a broken map. This gives every tenant a distinct look from a single maintained base while keeping the divergence to a small, reviewable patch per tenant.
Core Algorithm and Workflow
A multi-tenant map server cannot store one hand-edited MapLibre GL style per customer — that duplicates thousands of lines and guarantees drift the moment the base changes. Instead, treat styling as a cascade: one canonical base style, and per-tenant patches that describe only what differs. The resolver composes them deterministically in four steps, and the ordering of those steps is what keeps inheritance correct.
- Load the base style. Read one canonical MapLibre GL style JSON. Its
sources,layers, and defaultpaint/layoutblocks are the values every tenant inherits unless a patch says otherwise. This mirrors the single-source inheritance model described in the Theme Inheritance Systems overview. - Deep-merge the override chain. Fold each patch in order —
base, thenbrand, thentenant— so later layers win. The merge recurses into nested dicts key by key, but replaces lists wholesale (see below), and overwrites scalars. Order is significant: the last writer of any leaf value wins. - Resolve design tokens. Only after the full merge, walk the style and swap symbolic references such as
{color.brand.primary}for concrete values from the resolved token table. Deferring this means a tenant patch can redefine a token and have that redefinition reach every layer that references it. - Validate the resolved style. Run the merged, token-resolved output through a JSON Schema and the MapLibre style specification. A malformed override is rejected here, at build time, rather than reaching the browser as a blank or throwing map.
The subtle part is deep-merge semantics. Dictionaries must merge recursively so a tenant can override paint.fill-color on one layer without deleting that layer’s other paint keys. Lists, by contrast, must be replaced, not concatenated or index-merged: in a MapLibre style, an array is almost always a positional expression (["interpolate", ["linear"], ["zoom"], 8, ..., 16, ...]) or a stop array where position carries meaning. Element-wise merging of those arrays yields a corrupt interpolation curve. So the rule the resolver enforces everywhere is simple and predictable: dicts recurse, lists replace, scalars overwrite.
Production-Ready Python Implementation
The module below composes a tenant style end to end. It deep-merges the override chain with the dicts-recurse/lists-replace policy, resolves nested design tokens with a cycle guard, and validates the result against a JSON Schema using jsonschema==4.22.0. It ships with no third-party dependency beyond jsonschema; the MapLibre style spec check is stubbed as a structural schema you can swap for the official validator.
"""Resolve a per-tenant MapLibre GL style from a cascade of override patches."""
from __future__ import annotations
import copy
import re
from typing import Any
import jsonschema # jsonschema==4.22.0
TOKEN_RE = re.compile(r"\{([a-zA-Z0-9_.-]+)\}")
def deep_merge(base: dict, patch: dict) -> dict:
"""Return base merged with patch.
Merge policy (deterministic and MapLibre-safe):
* dict + dict -> recurse key by key
* list values -> replaced wholesale (positional/stop arrays are atomic)
* scalars -> patch overwrites base
* a null value in the patch deletes the key from the result
``base`` is not mutated; a deep copy is returned.
"""
result = copy.deepcopy(base)
for key, patch_val in patch.items():
if patch_val is None:
result.pop(key, None)
continue
base_val = result.get(key)
if isinstance(base_val, dict) and isinstance(patch_val, dict):
result[key] = deep_merge(base_val, patch_val)
else:
# lists and scalars replace outright — never element-wise merged
result[key] = copy.deepcopy(patch_val)
return result
def merge_chain(layers: list[dict]) -> dict:
"""Fold an ordered override chain (base -> brand -> tenant)."""
resolved: dict = {}
for layer in layers:
resolved = deep_merge(resolved, layer)
return resolved
def _lookup_token(path: str, tokens: dict) -> Any:
"""Resolve a dotted token path like 'color.brand.primary'."""
node: Any = tokens
for part in path.split("."):
if not isinstance(node, dict) or part not in node:
raise KeyError(f"unresolved token reference: {{{path}}}")
node = node[part]
return node
def _resolve_scalar(value: str, tokens: dict, _seen: frozenset) -> Any:
"""Resolve token references inside a single string value.
Handles nested tokens (a token whose value is itself a reference) and
guards against reference cycles.
"""
match = TOKEN_RE.fullmatch(value)
if match:
# Whole-string reference: preserve the target's native type
# (a color, a number, or a list-valued token).
path = match.group(1)
if path in _seen:
raise ValueError(f"token reference cycle through {{{path}}}")
resolved = _lookup_token(path, tokens)
return resolve_tokens(resolved, tokens, _seen | {path})
def _sub(m: re.Match) -> str:
path = m.group(1)
if path in _seen:
raise ValueError(f"token reference cycle through {{{path}}}")
resolved = _lookup_token(path, tokens)
return str(resolve_tokens(resolved, tokens, _seen | {path}))
# Interpolated reference inside a larger string, e.g. "hsl({...}, 40%)".
return TOKEN_RE.sub(_sub, value)
def resolve_tokens(node: Any, tokens: dict, _seen: frozenset = frozenset()) -> Any:
"""Recursively replace {token.path} references throughout a style tree."""
if isinstance(node, dict):
return {k: resolve_tokens(v, tokens, _seen) for k, v in node.items()}
if isinstance(node, list):
return [resolve_tokens(v, tokens, _seen) for v in node]
if isinstance(node, str) and TOKEN_RE.search(node):
return _resolve_scalar(node, tokens, _seen)
return node
# Minimal structural schema. Swap the "layers"/"sources" checks for the
# official MapLibre style specification validator in production.
STYLE_SCHEMA = {
"type": "object",
"required": ["version", "sources", "layers"],
"properties": {
"version": {"const": 8},
"sources": {"type": "object", "minProperties": 1},
"layers": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "type"],
"properties": {
"id": {"type": "string"},
"type": {
"enum": ["fill", "line", "symbol", "circle",
"raster", "background", "fill-extrusion",
"heatmap", "hillshade"]
},
},
},
},
},
}
def assert_no_unresolved_tokens(node: Any, _path: str = "$") -> None:
"""Fail loudly if any {token} survived resolution."""
if isinstance(node, dict):
for k, v in node.items():
assert_no_unresolved_tokens(v, f"{_path}.{k}")
elif isinstance(node, list):
for i, v in enumerate(node):
assert_no_unresolved_tokens(v, f"{_path}[{i}]")
elif isinstance(node, str) and TOKEN_RE.search(node):
raise ValueError(f"unresolved token at {_path}: {node!r}")
def resolve_tenant_style(
base_style: dict,
brand_patch: dict,
tenant_patch: dict,
tokens: dict,
) -> dict:
"""Compose, token-resolve, and validate one tenant's style.
Raises jsonschema.ValidationError, KeyError, or ValueError if the
override chain cannot produce a valid style. Callers should catch these
per tenant so one bad override never blocks the others.
"""
merged = merge_chain([base_style, brand_patch, tenant_patch])
resolved = resolve_tokens(merged, tokens)
assert_no_unresolved_tokens(resolved)
jsonschema.validate(instance=resolved, schema=STYLE_SCHEMA)
return resolved
# ---------------------------------------------------------------------------
# Example usage
# ---------------------------------------------------------------------------
if __name__ == "__main__":
base_style = {
"version": 8,
"sources": {"basemap": {"type": "vector", "url": "mbtiles://basemap"}},
"layers": [
{
"id": "water",
"type": "fill",
"source": "basemap",
"source-layer": "water",
"paint": {"fill-color": "{color.water}", "fill-opacity": 1.0},
},
{
"id": "roads",
"type": "line",
"source": "basemap",
"source-layer": "roads",
"paint": {
"line-color": "{color.brand.primary}",
"line-width": ["interpolate", ["linear"], ["zoom"],
8, 0.5, 16, 4],
},
},
],
}
tokens = {
"color": {
"water": "#a9d0f5",
"brand": {"primary": "{color.accent}", "accent": "#0b7285"},
}
}
brand_patch: dict = {} # brand tier unchanged in this example
tenant_patch = {
"layers": [
# lists replace: the tenant must supply the full layer array to
# reorder or recolor. Here they widen roads at high zoom only.
{
"id": "water",
"type": "fill",
"source": "basemap",
"source-layer": "water",
"paint": {"fill-color": "{color.water}", "fill-opacity": 0.9},
},
{
"id": "roads",
"type": "line",
"source": "basemap",
"source-layer": "roads",
"paint": {
"line-color": "#c92a2a", # tenant hardcodes its own red
"line-width": ["interpolate", ["linear"], ["zoom"],
8, 0.5, 16, 6],
},
},
]
}
style = resolve_tenant_style(base_style, brand_patch, tenant_patch, tokens)
assert style["layers"][1]["paint"]["line-color"] == "#c92a2a"
assert style["layers"][0]["paint"]["fill-color"] == "#a9d0f5"
print("resolved and validated:", len(style["layers"]), "layers")
Two behaviours are worth tracing. First, the roads line-color in the base is the token {color.brand.primary}, which itself points at {color.accent} — nested indirection the resolver follows to #0b7285 when a tenant leaves it alone. Second, because lists replace, the tenant patch supplies the whole layers array; it cannot patch a single layer in place through the array. If your tenants only ever tweak paint, key layers by id in a dict-shaped intermediate representation so per-layer dict merging applies — a trade-off covered in the tuning notes below.
Performance Tuning and Cartographic Best Practices
-
Resolve once, cache the output, invalidate on any input hash change. Style resolution is deterministic in
(base, brand, tenant, tokens), so hash those four inputs and memoize the resolved style. Serve the cached JSON from the map server and only recompute when a hash changes. The invalidation trigger is identical in spirit to basemap tile cache and invalidation — a base-style bump must fan out to every tenant’s cached style just as a data change fans out to tiles. -
Key layers by id if tenants patch layers in place. The list-replace rule is safe but blunt: a tenant editing one paint value must resubmit every layer. If that friction is real, transform
layersinto an{id: layer}dict before merging and back to a list (preserving order) afterward, so per-layer dict merging applies. Keep list-replace as the default and opt specific keys into this treatment explicitly — never flip the global list policy to append. -
Give tokens a strict namespace and fail on unknown paths. A typo like
{color.brand.primry}must raise, not silently stringify into the output. Theassert_no_unresolved_tokenspass guarantees no{...}survives, and_lookup_tokenraisesKeyErroron unknown paths. Treat both as build-breaking. This is the same fail-loud discipline that keeps a rule-based styling engine from emitting silently wrong symbology. -
Validate the resolved style, not the patch. An individually valid tenant patch can still produce an invalid merged style — for instance by referencing a
source-layerthe base removed. Only the fully composed, token-resolved output tells the truth, so run schema and MapLibre style-spec validation there. Wire it into CI so one tenant’s bad override fails only that tenant’s build. -
Pin the base-style version and treat it as an API. Tenants patch against a specific base revision. When you restructure the base (rename a layer
id, split a source), bump a base version and re-resolve every tenant so breakage surfaces immediately. This coordinates naturally with the deploy step of a headless rendering engine, which consumes the resolved styles to rasterize preview thumbnails per tenant.
Integration and Next Steps
The resolver returns a plain dict that serializes directly to a MapLibre GL style JSON, so it drops into most delivery paths without adaptation:
- Client delivery. Serialize per tenant with
json.dumps(style)and serve it at/styles/{tenant_id}/style.json. The MapLibre GL JS client fetches it exactly as it would a hand-written style; inheritance is invisible to the browser. - Server-side rendering. Feed the resolved
dictto a headless renderer to bake per-tenant static maps or social preview images, gated behind the same validation so no tenant ever renders a broken sheet. - CI validation gate. Iterate every
(brand, tenant)pair in a build step, callresolve_tenant_style, and collect failures into a report. A single malformed override fails its own row without blocking the deploy of healthy tenants. - Design-token pipeline. Source the
tokenstable from a design system export (Style Dictionary or a Figma tokens plugin) so a brand color change flows from design into every tenant map through one token edit rather than a code change.
To extend the cascade with a light/dark axis, resolve each tenant twice against a light and a dark token table and emit two style documents — the mechanics of that split are covered in Implementing Dark and Light Theme Inheritance for Web Maps, which shares this resolver’s deep-merge core.
Frequently Asked Questions
Should list values be merged or replaced during a deep merge?
Replace them. In a MapLibre GL style, arrays are almost always positional data-driven expressions or stop arrays where index carries meaning, so element-wise merging produces a corrupt interpolation curve. A tenant that wants to change one stop must supply the entire array. The predictable rule is: dicts recurse, lists replace, scalars overwrite. If you genuinely need append semantics for a specific key such as top-level layers, handle that key explicitly rather than changing the global list policy.
Why resolve design tokens after merging instead of before?
Because a tenant override may redefine a token as well as consume one. If you resolve {color.brand.primary} to a hex value in the base before merging, a tenant patch that redefines color.brand.primary can no longer affect layers that already baked in the old value. Keeping references symbolic until the whole cascade is merged means the last writer of each token wins — the inheritance behaviour tenants expect. Resolve tokens exactly once, as the final step before validation.
How do I stop a malformed tenant override from shipping a broken map?
Validate the resolved style, not the patch. A patch can be individually valid yet produce an invalid style once merged — for example by pointing a layer at a source-layer that no longer exists. Run the fully resolved output through a JSON Schema check for structure plus a MapLibre style-spec validation for semantics, and treat any unresolved token reference as a hard error. Wire this into CI so a bad override fails the build for that one tenant without blocking the others.
Related
- Theme Inheritance Systems — the parent overview covering how map styles inherit and override across a shared base.
- Implementing Dark and Light Theme Inheritance for Web Maps — applies this same deep-merge and token-resolution core to a light/dark theme axis.
- Rule-Based Styling Engines — the fail-loud symbology discipline that pairs with cascading overrides for predictable multi-tenant output.
Back to Theme Inheritance Systems