Documentation

tileserver-rs can serve a self-contained, interactive map page designed to be dropped into an <iframe>. It boots MapLibre GL JS from a pinned CDN, loads the requested style, and exposes a small bidirectional postMessage API so the parent page can drive the map (and listen to its events).

Use it for:

  • Blog posts and documentation embeds
  • Dashboards and reports
  • Story maps and "scrollytelling" previews (with interactive=false)
  • Any third-party context where you want a live map without shipping your own MapLibre bundle

Basic Usage

GET /embed/{style}?center={lat},{lng}&zoom={zoom}
# San Francisco at zoom 10
curl "http://localhost:8080/embed/protomaps-light?center=37.8,-122.4&zoom=10"

# Fit a bounding box instead of a center
curl "http://localhost:8080/embed/protomaps-light?bounds=-122.5,37.7,-122.3,37.9"

Drop it into a page:

<iframe
  src="http://localhost:8080/embed/protomaps-light?center=37.8,-122.4&zoom=10"
  width="640"
  height="420"
  style="border:0"
  loading="lazy"
  title="Map"
></iframe>

Query Parameters

ParameterTypeDefaultDescription
centerstringlat,lng (e.g. 37.8,-122.4). lat ∈ [-90, 90], lng ∈ [-180, 180].
zoomnumber2Clamped to 0..=22.
bearingnumber0Camera rotation in degrees, clamped to -360..=360.
pitchnumber0Camera tilt in degrees, clamped to 0..=85.
boundsstringminLng,minLat,maxLng,maxLat. When present, overrides center.
markersstringPipe-separated lng,lat pairs, e.g. -122.4,37.8|0,0.
controlsstringnavigationComma-separated: navigation, scale, fullscreen. Unknown tokens dropped.
hashbooleanfalseSync map position to the URL hash.
interactivebooleantrueWhen false, all pointer/keyboard input is disabled (preview mode).
themestringautolight or dark. Absent → follows prefers-color-scheme.

postMessage API

The embed talks to its parent window via postMessage.

Parent → embed

const frame = document.querySelector('iframe').contentWindow;

frame.postMessage({ type: 'flyTo', lng: -122.4, lat: 37.8, zoom: 12 }, '*');
frame.postMessage({ type: 'fitBounds', bounds: [[-122.5, 37.7], [-122.3, 37.9]], padding: 32 }, '*');
frame.postMessage({ type: 'setFilter', layerId: 'roads', filter: ['==', 'class', 'motorway'] }, '*');
frame.postMessage({ type: 'setLayoutProperty', layerId: 'roads', property: 'visibility', value: 'none' }, '*');

Embed → parent

window.addEventListener('message', (ev) => {
  const d = ev.data || {};
  if (d.type === 'ready') {
    // Map finished loading. d.style is the style URL.
  } else if (d.type === 'move') {
    // Throttled to ~200ms. d.lng, d.lat, d.zoom, d.bearing, d.pitch.
  } else if (d.type === 'click') {
    // d.lng, d.lat, and d.features (array of clicked layer ids).
  }
});

Theme Handling

Pass ?theme=light or ?theme=dark to force a theme. When omitted, the page reads matchMedia("(prefers-color-scheme: dark)") on the client and sets data-theme accordingly — no server round-trip and no reliance on client hints that not every browser sends.

Preview (non-interactive) Mode

GET /embed/{style}?center=37.8,-122.4&zoom=10&interactive=false

With interactive=false, the map disables drag-pan, scroll-zoom, double-click zoom, keyboard, touch, box-zoom, and drag-rotate. The map object still exists, so the postMessage API keeps working — ideal for parent-driven story maps.

XSS Safety

Every query parameter is parsed into a typed value at the boundary: numbers are validated (f64, ranges enforced, NaN/Infinity rejected), controls/theme are whitelisted enums, and markers are built from validated coordinate pairs. The style id and theme are additionally HTML-escaped before injection. As a result, no query parameter can inject markup — you can safely embed this endpoint in any context.

Notes

  • This endpoint serves HTML only and is not gated by server.disable_render.
  • MapLibre GL JS is loaded from a pinned CDN (unpkg.com/maplibre-gl@5.6.1); no bundle ships in the server binary.