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.

Theme override cascade merging into one resolved style Three stacked layers labelled Base style, Brand overrides, and Tenant overrides feed a deep-merge step that produces a merged style; that style then passes through token resolution and schema validation to produce the resolved tenant style. Base style shared source of truth Brand overrides token + palette patch Tenant overrides per-customer patch Deep merge dicts recurse, lists replace Resolve tokens Validate schema emitted after validation passes Resolved tenant style
  1. Load the base style. Read one canonical MapLibre GL style JSON. Its sources, layers, and default paint/layout blocks 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.
  2. Deep-merge the override chain. Fold each patch in order — base, then brand, then tenant — 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.
  3. 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.
  4. 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 layers into 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. The assert_no_unresolved_tokens pass guarantees no {...} survives, and _lookup_token raises KeyError on 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-layer the 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 dict to 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, call resolve_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 tokens table 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.


Back to Theme Inheritance Systems