Integrating Indoor Maps with Mapbox GL JS

This reference covers rendering a validated indoor map in Mapbox GL JS and, in particular, drawing a computed route on top of it as a layer that updates in place — and it sits inside the SDK Integration Patterns workflow, downstream of the artifact that has already been version-negotiated for the browser.

What “Integrating an Indoor Map into Mapbox” Means

Mapbox GL JS is the commercial WebGL renderer that MapLibre was forked from. The two still share the same style specification — sources, layers, and filter expressions — so the mental model of “one source per building, filter by level” is identical. What differs is everything around the edges: Mapbox GL JS from v2 onward ships under a proprietary licence, requires an accessToken on every map instance, meters map loads, and pulls its glyphs, sprites, and default basemap from Mapbox-hosted endpoints unless you override them. If you have read the companion Integrating Indoor Maps with MapLibre GL JS reference, treat this page as the same rendering model with a billing account attached and one extra job: rendering a live, recomputable route line.

The token is not a formality. Without a valid accessToken the map silently fails to load tiles and you get a blank canvas with a 401 buried in the network tab. And because the two libraries have diverged since the fork, expression syntax and some API surfaces are version-sensitive: an expression written for Mapbox GL JS v1 can behave differently under v3, so pin the library version and read the release notes when you upgrade rather than assuming a copied MapLibre snippet ports verbatim.

The indoor-specific work is the same three moves as any GL renderer — load every floor into one source, paint filtered fill/line/symbol layers, switch floors by rewriting the level filter — plus the piece this page focuses on: a dedicated route source whose data you replace with setData each time wayfinding recomputes a path. The geometry underneath is trusted; it conforms to the contract in JSON Schema Design for Indoor Maps and the client only presents it.

Minimal Working Example: Token, Floors, and a Route Layer

The example sets the access token, adds the indoor source and a floor-filtered POI symbol layer, and — the part unique to this page — declares an empty route source up front so the route line layer exists before there is any path to draw. Rendering a route is then just replacing that source’s data.

import mapboxgl, { Map as MbMap, ExpressionSpecification } from "mapbox-gl";

mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_TOKEN;   // required — no token, no tiles

function levelFilter(level: number): ExpressionSpecification {
  return ["==", ["get", "level"], level];
}

export function initIndoorMap(container: string, envelopeUrl: string): MbMap {
  const map = new mapboxgl.Map({ container, style: "mapbox://styles/mapbox/light-v11", zoom: 18, center: [0, 0] });

  map.on("load", () => {
    map.addSource("indoor", { type: "geojson", data: envelopeUrl });          // all floors, one source
    map.addLayer({ id: "rooms", type: "fill", source: "indoor", filter: levelFilter(1),
      paint: { "fill-color": "#e6f0f3", "fill-outline-color": "#7fb2bd" } });
    map.addLayer({ id: "poi", type: "symbol", source: "indoor",
      filter: ["all", levelFilter(1), ["has", "poi_name"]],
      layout: { "text-field": ["get", "poi_name"], "text-size": 11, "text-anchor": "top" } });

    // Declare the route source empty so the layer exists before a path is computed.
    map.addSource("route", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
    map.addLayer({ id: "route-line", type: "line", source: "route",
      layout: { "line-cap": "round", "line-join": "round" },
      paint: { "line-color": "#1f9aa6", "line-width": 5, "line-opacity": 0.9 } });
  });

  return map;
}

Rendering and Updating the Route

A route arrives as an ordered list of [lng, lat] coordinates from the routing engine. To draw it, wrap it in a single LineString feature and hand it to the route source with setData. Because the layer already exists, the map re-renders immediately; there is no add/remove churn and no flicker. Every recompute — a reroute after a wrong turn, a new destination, a floor change — is another setData call on the same source.

export function renderRoute(map: MbMap, coords: [number, number][], level: number): void {
  const src = map.getSource("route") as mapboxgl.GeoJSONSource | undefined;
  if (!src) throw new Error("route source missing — call initIndoorMap first");
  const feature = {
    type: "Feature" as const,
    geometry: { type: "LineString" as const, coordinates: coords },
    properties: { level },
  };
  src.setData({ type: "FeatureCollection", features: coords.length ? [feature] : [] });
  map.setFilter("route-line", ["==", ["get", "level"], level]);   // hide the leg on other floors
}
Mapbox route rendering: replace the route source data in place The routing engine emits an ordered list of longitude and latitude coordinates for the current path. The client wraps those coordinates in a single LineString feature and calls setData on a route source that was declared empty during map initialisation. Because the route-line layer already references that source, Mapbox re-renders it in place with no add or remove of layers, avoiding flicker. Every reroute — a wrong-turn recovery, a new destination, or a floor change — is another setData call on the same source, and a per-level filter on the route-line layer hides the drawn leg on any floor other than the active one. A reroute is one setData call, not a layer rebuild routing engine ordered [lng, lat] for the active leg wrap as LineString one Feature carries level route source setData(featureCollection) layer already exists route-line layer re-renders in place no add/remove · per-level filter hides other floors reroute → same call

Validating the Route Payload Server-Side

If the engine ever emits a coordinate frame mismatch or an unclosed leg, the route silently renders in the wrong place. This typed helper validates a computed route against the building’s bounds and logs any point that falls outside them before the payload is sent to the browser.

import logging

from shapely.geometry import LineString, box

logger = logging.getLogger(__name__)


def validate_route(coords: list[tuple[float, float]], bounds: tuple[float, float, float, float]) -> LineString:
    """Check a route's coordinates fall inside the floor's WGS84 bounds before it reaches the client."""
    if len(coords) < 2:
        raise ValueError("a route needs at least two coordinates")
    envelope = box(*bounds)  # (minLng, minLat, maxLng, maxLat)
    line = LineString(coords)
    if not envelope.contains(line):
        outside = [c for c in coords if not envelope.contains(LineString([c, c]).centroid)]
        logger.error("route leaves floor bounds at %d point(s): %s", len(outside), outside[:3])
        raise ValueError("route coordinates outside floor bounds — check coordinate frame")
    logger.info("route ok: %d points, length=%.1f", len(coords), line.length)
    return line

Mapbox Indoor Reference

Concept API / value Notes
Access token mapboxgl.accessToken = "pk...." Required on every instance; a missing token yields a silent blank canvas
Base style style: "mapbox://styles/mapbox/light-v11" Mapbox-hosted; override with a self-hosted style URL to avoid metered loads
Indoor source { type: "geojson", data: url } One source, all floors, features carry level
Floor filter ["==", ["get", "level"], n] Expression syntax is version-sensitive between Mapbox v1/v2/v3 — pin your version
POI layer type: "symbol" text-field: ["get", "poi_name"]; sprites/glyphs load from Mapbox unless overridden
Route source { type: "geojson", data: emptyFC } Declared empty at init so the route layer exists before a path is computed
Route layer type: "line" line-cap: round, line-join: round; updated by setData, never re-added
Update a route getSource("route").setData(fc) Replaces the line in place; a reroute is one more call

Common Errors & Fixes

Blank map, 401 Unauthorized on tile requests. No valid accessToken, an expired token, or a token whose URL restrictions exclude your origin. Set the token before constructing any map and confirm the token’s allowed URLs include your domain:

mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_TOKEN;
if (!mapboxgl.accessToken) throw new Error("VITE_MAPBOX_TOKEN is not set");

Expected value to be of type number, but found string in a filter. An expression copied from a MapLibre or older-Mapbox example is being parsed by a different GL JS version, or the level property is a string in the data but compared as a number in the filter. Normalise the type in the expression rather than assuming the data is clean:

map.setFilter("rooms", ["==", ["to-number", ["get", "level"]], activeLevel]);

The route never appears even though the engine returned coordinates. The route source was never declared, so getSource("route") is undefined and setData throws (or you added a fresh layer each recompute and are stacking dead layers). Declare the route source empty during load and only ever call setData on it — never addSource/addLayer per route.

Integration Point

This renderer is the browser-side terminus of the delivery path. It draws the validated FeatureCollection produced under JSON Schema Design for Indoor Maps, and the route it paints is recomputed and pushed with setData whenever wayfinding changes. Because a route line is tied to a specific map version, a mid-session basemap update must reconcile against the freshness signals from Cache Invalidation Strategies so a stale route is never drawn over fresh geometry. If your delivery target is the web and you want to avoid metered map loads and a proprietary licence, the open-source Integrating Indoor Maps with MapLibre GL JS companion covers the same source-and-layer model without a token.

Frequently Asked Questions

Can I copy a MapLibre indoor style straight into Mapbox GL JS?

The source, layer, and filter structure ports because both descend from the same style specification, so “one source, filter by level” works in both. What does not port cleanly is the surrounding contract: Mapbox GL JS v2+ needs an accessToken, meters map loads, and pulls glyphs and sprites from Mapbox-hosted endpoints, and its expression parser has diverged from MapLibre since the fork. Pin your Mapbox version, add the token, and re-test any expression that used newer syntax rather than assuming a verbatim copy behaves identically.

What is the right way to update a route as the user reroutes?

Declare the route source once, empty, when the map loads, and add the line layer that reads from it at the same time. Every time the engine recomputes a path, wrap the new coordinates in a single LineString feature and call setData on that source. Mapbox re-renders the existing layer in place with no flicker, and you never add or remove layers, which is what avoids the classic bug of stacking a new dead route layer on every recompute.

Why does my route render in the wrong place or off the building?

The route coordinates are in a different frame from the rendered geometry — commonly raw metres versus longitude/latitude, or a route computed against a different floor’s extent. Validate the route against the floor’s bounds before it reaches the client and reproject it into the same frame the envelope uses. Mapbox draws exactly the coordinates it is handed and never reprojects a GeoJSON source for you, so a frame mismatch shows up as a leg drawn in empty space.

This page is part of the SDK Integration Patterns guide within the Production-Ready Indoor Map Deployment reference.