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
| Parameter | Type | Default | Description |
|---|---|---|---|
center | string | — | lat,lng (e.g. 37.8,-122.4). lat ∈ [-90, 90], lng ∈ [-180, 180]. |
zoom | number | 2 | Clamped to 0..=22. |
bearing | number | 0 | Camera rotation in degrees, clamped to -360..=360. |
pitch | number | 0 | Camera tilt in degrees, clamped to 0..=85. |
bounds | string | — | minLng,minLat,maxLng,maxLat. When present, overrides center. |
markers | string | — | Pipe-separated lng,lat pairs, e.g. -122.4,37.8|0,0. |
controls | string | navigation | Comma-separated: navigation, scale, fullscreen. Unknown tokens dropped. |
hash | boolean | false | Sync map position to the URL hash. |
interactive | boolean | true | When false, all pointer/keyboard input is disabled (preview mode). |
theme | string | auto | light 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.