Switching Between OpenStreetMap and Mapbox Basemaps at Runtime

Part of the Base Layer Selection & Switching guide.

Operative rule: capture center, zoom, bearing, and pitch before every setStyle() call and re-apply them after the new style loads — a style swap resets the camera and destroys every source and layer you added imperatively.

How Runtime Style Swapping Works

A basemap in maplibregl is not a single tile URL — it is a complete style document: a JSON object listing sources, layers, sprite and glyph endpoints, and default camera state. Switching from an OpenStreetMap raster basemap to a Mapbox vector basemap means replacing that entire document with map.setStyle(newStyle). The critical consequence is that setStyle() is destructive: MapLibre tears down the old style graph and builds a new one from scratch. Any source or layer you added at runtime — GeoJSON overlays, heatmaps, marker layers — is not part of the incoming style JSON, so it vanishes the moment the swap completes.

Two pieces of state therefore need explicit preservation. The first is the camera: center, zoom, bearing, and pitch. MapLibre GL v4 preserves the camera across setStyle() by default, but a style document that hard-codes a center or zoom at its top level will override the live viewport unless you re-apply it. The second is your overlay graph. Because overlays must be re-added after the new style parses, you hook the styledata event (or map.once('style.load', …)), which fires when the incoming style finishes loading. This same discipline underpins a dark-mode basemap toggle and pairs naturally with the best base map providers for high-contrast geo-dashboards, where legibility of overlays depends on the basemap underneath them.

Runtime Basemap Switch Sequence A sequence showing that before setStyle the controller snapshots camera state and overlay definitions, then setStyle destroys the old style graph, then on the styledata event the controller restores the camera with jumpTo and re-adds the overlay sources and layers. 1. Snapshot state center / zoom / bearing overlay source + layer defs 2. map.setStyle(next) old graph destroyed overlays removed 3. styledata event new style parsed safe to re-add 4. Restore map.jumpTo(camera) — re-add overlay sources + layers viewport identical, overlays back on top reuse snapshot

Production-Ready Implementation

The controller below manages two named basemaps — an OpenStreetMap raster style and a Mapbox vector style — and exposes a single switchTo(id) method. It snapshots the camera, swaps the style, and re-registers overlays inside the styledata handler. Overlay definitions are kept in a registry so re-adding is a plain loop rather than duplicated code.

// MapLibre GL JS v4 — runtime basemap switcher with overlay preservation.
import maplibregl from "maplibre-gl";

const MAPBOX_TOKEN = window.MAPBOX_TOKEN || ""; // injected at build time

// Two named basemap styles. OSM raster needs no key; Mapbox vector does.
const BASEMAPS = {
  osm: {
    version: 8,
    sources: {
      "osm-raster": {
        type: "raster",
        tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
        tileSize: 256,
        attribution: "© OpenStreetMap contributors",
      },
    },
    layers: [{ id: "osm-raster", type: "raster", source: "osm-raster" }],
  },
  mapbox: MAPBOX_TOKEN
    ? `https://api.mapbox.com/styles/v1/mapbox/streets-v12?access_token=${MAPBOX_TOKEN}`
    : null, // null signals "unavailable"; the switcher falls back to OSM
};

// Overlay registry: each entry is re-added after every style swap.
const OVERLAYS = [
  {
    id: "incidents",
    source: { type: "geojson", data: "/data/incidents.geojson" },
    layer: {
      id: "incidents",
      type: "circle",
      source: "incidents",
      paint: {
        "circle-radius": 5,
        "circle-color": "#e6550d",
        "circle-stroke-width": 1,
        "circle-stroke-color": "#ffffff",
      },
    },
  },
];

const map = new maplibregl.Map({
  container: "map",
  style: BASEMAPS.osm,
  center: [-74.006, 40.7128],
  zoom: 11,
});

/** Re-add every registered overlay if it is not already present. */
function addOverlays() {
  for (const o of OVERLAYS) {
    if (!map.getSource(o.id)) map.addSource(o.id, o.source);
    if (!map.getLayer(o.layer.id)) map.addLayer(o.layer);
  }
}

// Initial overlay load once the first style is ready.
map.on("load", addOverlays);

/**
 * Swap the active basemap while preserving the viewport and overlays.
 * @param {"osm"|"mapbox"} id
 */
function switchTo(id) {
  const nextStyle = BASEMAPS[id];
  if (!nextStyle) {
    console.warn(`Basemap "${id}" unavailable (missing key); staying on OSM.`);
    return; // graceful no-op instead of a blank canvas
  }

  // 1. Snapshot the camera BEFORE the destructive swap.
  const camera = {
    center: map.getCenter(),
    zoom: map.getZoom(),
    bearing: map.getBearing(),
    pitch: map.getPitch(),
  };

  // 2. Re-add overlays + restore camera once the new style has parsed.
  //    { once: true } avoids stacking handlers across repeated switches.
  map.once("styledata", () => {
    map.jumpTo(camera); // restore exact viewport
    addOverlays();      // rebuild overlay graph on top of new basemap
  });

  // 3. Perform the swap. diff:false forces a full rebuild between
  //    raster (OSM) and vector (Mapbox) styles, which are not diff-able.
  map.setStyle(nextStyle, { diff: false });
}

// Fall back to OSM if any tile/style request errors (e.g. bad token).
map.on("error", (e) => {
  if (String(e.error?.message || "").includes("401")) {
    console.error("Mapbox auth failed; reverting to OpenStreetMap.");
    switchTo("osm");
  }
});

// Wire the UI control.
document
  .querySelector("#basemap-select")
  ?.addEventListener("change", (e) => switchTo(e.target.value));

Alternative Variants

Leaflet tile-layer swap (raster only)

Leaflet has no monolithic style document, so switching basemaps means adding one L.tileLayer and removing the other. The camera never resets because Leaflet does not tear down the map, and overlays added as separate L.Layer objects survive untouched. This is the simpler model when both basemaps are raster tiles and you do not need vector styling. It also composes cleanly with server-side bounds set when configuring maxBounds and minZoom in Leaflet via Python.

// Leaflet raster basemap swap — no camera reset, overlays persist.
const bases = {
  osm: L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: "© OpenStreetMap contributors",
    maxZoom: 19,
  }),
  satellite: L.tileLayer(
    "https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/{z}/{x}/{y}?access_token=" +
      MAPBOX_TOKEN,
    { attribution: "© Mapbox © OpenStreetMap", tileSize: 512, zoomOffset: -1 }
  ),
};

const map = L.map("map", { center: [40.7128, -74.006], zoom: 11, layers: [bases.osm] });
let active = "osm";

function switchTo(id) {
  if (id === active || !bases[id]) return;
  map.removeLayer(bases[active]); // camera untouched — no state to restore
  map.addLayer(bases[id]);
  active = id;
}

Basemap capability reference

Concern OpenStreetMap raster Mapbox vector
API key required No Yes (access_token)
Style format raster tile URL Style Spec JSON
Runtime relabel / recolor No Yes (setPaintProperty)
Attribution obligation © OpenStreetMap Mapbox + OSM
setStyle diff-able n/a (raster) Only vs another vector style
Blank-on-missing-key risk None High — needs fallback

Verification Steps

  • Camera continuity — log map.getCenter() and map.getZoom() immediately before and after a swap; the values must be identical to within floating-point noise.
  • Overlay survival — after switching, run map.getLayer('incidents') in the console; a non-undefined return confirms the overlay was re-added on the new basemap.
  • Missing-key fallback — deploy with an empty MAPBOX_TOKEN and confirm the switcher logs a warning and stays on OSM instead of rendering a blank canvas.
  • Attribution present — inspect the map’s attribution control after each swap; the correct provider credit must appear for whichever basemap is active.
  • No handler stacking — switch back and forth ten times, then confirm only one styledata restore runs per swap (the { once: true } guard prevents accumulation).

Common Errors & Fixes

Overlays vanish after switching basemaps

setStyle() replaced the whole style graph, discarding sources and layers you added imperatively. The fix is to re-add them inside a styledata (or map.once('style.load', …)) handler, exactly as addOverlays() does above. Never re-add overlays synchronously right after setStyle() — the new style has not parsed yet, and addLayer will throw Style is not done loading.

Map jumps to a different location after the swap

The incoming style document declares a top-level center and zoom that override the live viewport. Snapshot the camera before the swap and call map.jumpTo(camera) in the styledata handler. Prefer jumpTo over flyTo here so the transition is instantaneous rather than animating away from the user’s current view.

Mapbox basemap renders blank with 401 errors in the console

The access_token is missing, expired, or scoped without style read permission. Requests to the Mapbox tile endpoint return 401 Unauthorized and the canvas stays empty. Guard by treating a null style entry as “unavailable” and falling back to the OpenStreetMap raster style, and attach a map.on('error', …) handler that reverts on auth failure rather than leaving users staring at a void.

Attribution disappears or shows the wrong provider

Each basemap carries its own legal attribution, and a style swap replaces the attribution string along with everything else. Set attribution on every source (maplibregl) or tileLayer (Leaflet) so the correct credit re-appears automatically. Dropping OpenStreetMap or Mapbox attribution violates their usage terms, so treat this as a release blocker, not a cosmetic detail.