Example
FeatureServer layer in MapLibre GL JS
One HTML file loads a GeoServices FeatureServer layer as GeoJSON, adds it to MapLibre GL JS as a GeoJSON source, fits the map to it and opens a popup on click.
CODE FIRSTsrc/index.htmlExpand source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FeatureServer layer in MapLibre</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.4.1/dist/maplibre-gl.css"
integrity="sha384-Q5Blg3vUVAlUKqIPJYz7wGnz40Vwrx4pVuFVicI73+8c/26Zr5hhckfuIiIUflLE" crossorigin="anonymous" />
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.4.1/dist/maplibre-gl.mjs" crossorigin="anonymous" />
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.4.1/dist/maplibre-gl-shared.mjs" crossorigin="anonymous" />
<style>
html, body { height: 100%; margin: 0; }
body { display: grid; grid-template-rows: auto minmax(320px, 1fr) auto; font: 15px/1.45 system-ui, sans-serif; color: #17221c; background: #f4f1e8; }
header { padding: 0.75rem 1rem; }
h1 { margin: 0 0 0.25rem; font-size: 1.15rem; }
#status { margin: 0; }
#map { min-height: 320px; }
pre { margin: 0; padding: 0.5rem 1rem; max-height: 7rem; overflow: auto; font-size: 12px; background: #fffdf6; border-top: 1px solid #b9b5a8; }
[data-state="pass"] { color: #17633a; }
[data-state="fail"] { color: #a12b1f; }
.maplibregl-popup-content table { border-collapse: collapse; font-size: 12px; }
.maplibregl-popup-content td { padding: 1px 6px 1px 0; vertical-align: top; }
</style>
</head>
<body data-sample-status="loading">
<header>
<h1>A FeatureServer layer as a MapLibre GeoJSON source</h1>
<p id="status">Loading the layer...</p>
</header>
<div id="map" role="region" aria-label="Map"></div>
<pre id="requests" aria-label="Requests this page made"></pre>
<script type="module">
// MapLibre GL JS 6 is published as ES modules; importing it dynamically lets
// the layer requests below start while the library downloads.
const maplibreReady = import("https://cdn.jsdelivr.net/npm/maplibre-gl@6.4.1/dist/maplibre-gl.mjs");
const params = new URLSearchParams(location.search);
const baseUrl = (params.get("baseUrl") ?? "https://demo.honua.io").replace(/\/+$/, "");
const service = params.get("service") ?? "maui-place-names";
const layerId = params.get("layer") ?? "6";
const where = params.get("where") ?? "1=1";
const labelField = params.get("labelField") ?? "name";
const status = document.querySelector("#status");
const requests = document.querySelector("#requests");
function logRequest(url) {
requests.textContent += `GET ${url}\n`;
}
async function getJson(url) {
logRequest(url);
const response = await fetch(url);
const body = await response.json().catch(() => ({}));
if (!response.ok || body.error) throw new Error(body.error?.message ?? `HTTP ${response.status}`);
return body;
}
// Reads `field` from feature properties; a dotted path ("properties.name")
// walks nested objects, including nested values a server returns as JSON text.
function readField(properties, field) {
return field.split(".").reduce((value, key) => {
if (typeof value === "string" && value.startsWith("{")) value = JSON.parse(value);
return value?.[key];
}, properties);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}
// Optional basemap: the server's own Protomaps PMTiles archive, used only
// when it answers with a PMTiles header. Otherwise the plain background stays.
async function addBasemapIfAvailable(map, beforeId) {
if (params.get("basemap") === "none") return "off";
const url = `${baseUrl}/api/v1/tiles/pmtiles/maui-basemap`;
try {
logRequest(`${url} (Range: bytes=0-6, basemap probe)`);
const response = await fetch(url, { headers: { Range: "bytes=0-6" } });
const magic = new TextDecoder().decode(await response.arrayBuffer()).slice(0, 7);
if (!response.ok || magic !== "PMTiles") return `not available (HTTP ${response.status}), plain background`;
await new Promise((resolve, reject) => {
const script = Object.assign(document.createElement("script"), {
src: "https://cdn.jsdelivr.net/npm/pmtiles@4.5.0/dist/pmtiles.js",
integrity: "sha384-pbKNZbDshUsLnZMIVsn/t+j3xEY9PGfuX5Z22l5rcy/obXz3EwaMx1F5d9sRsJMn",
crossOrigin: "anonymous",
onload: resolve,
onerror: () => reject(new Error("pmtiles.js did not load")),
});
document.head.append(script);
});
maplibregl.addProtocol("pmtiles", new window.pmtiles.Protocol().tile);
map.addSource("basemap", { type: "vector", url: `pmtiles://${url}` });
map.addLayer({ id: "basemap-earth", type: "fill", source: "basemap", "source-layer": "earth", paint: { "fill-color": "#e7e3d6" } }, beforeId);
map.addLayer({ id: "basemap-water", type: "fill", source: "basemap", "source-layer": "water", paint: { "fill-color": "#c9dbe3" } }, beforeId);
map.addLayer({ id: "basemap-roads", type: "line", source: "basemap", "source-layer": "roads", paint: { "line-color": "#ffffff", "line-width": 1 } }, beforeId);
return "Protomaps PMTiles from the same server";
} catch (error) {
return `not available (${error.message}), plain background`;
}
}
function bboxOf(features) {
const box = [Infinity, Infinity, -Infinity, -Infinity];
const visit = (coords) => {
if (typeof coords[0] === "number") {
box[0] = Math.min(box[0], coords[0]); box[1] = Math.min(box[1], coords[1]);
box[2] = Math.max(box[2], coords[0]); box[3] = Math.max(box[3], coords[1]);
} else coords.forEach(visit);
};
for (const feature of features) if (feature.geometry) visit(feature.geometry.coordinates);
return box;
}
async function queryAll(layerUrl, pageSize) {
// Page through the layer: a server returns at most maxRecordCount
// features per request and flags exceededTransferLimit when more remain.
const features = [];
for (let offset = 0; ; offset += pageSize) {
const query = new URLSearchParams({
where, outFields: "*", returnGeometry: "true", outSR: "4326",
resultOffset: String(offset), resultRecordCount: String(pageSize), f: "geojson",
});
const page = await getJson(`${layerUrl}/query?${query}`);
features.push(...(page.features ?? []));
const more = page.exceededTransferLimit ?? page.properties?.exceededTransferLimit;
if (!more || !page.features?.length) return { type: "FeatureCollection", features };
}
}
const layerUrl = `${baseUrl}/rest/services/${encodeURIComponent(service)}/FeatureServer/${encodeURIComponent(layerId)}`;
const layerRequest = getJson(`${layerUrl}?f=json`);
layerRequest.catch(() => {});
const maplibregl = await maplibreReady;
const map = new maplibregl.Map({
container: "map",
style: { version: 8, sources: {}, layers: [{ id: "background", type: "background", paint: { "background-color": "#eef2ee" } }] },
center: [-156.33, 20.8],
zoom: 8,
attributionControl: { compact: true, customAttribution: "Data: Honua demo (Maui)" },
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
const mapLoaded = new Promise((resolve) => map.once("load", resolve));
window.sampleMap = map;
try {
const layer = await layerRequest;
const [geojson] = await Promise.all([
queryAll(layerUrl, Math.min(Number(layer.maxRecordCount) || 1000, 10000)),
mapLoaded,
]);
if (!geojson.features?.length) throw new Error("The query returned no features.");
map.addSource("layer", { type: "geojson", data: geojson });
const isType = (...types) => ["match", ["geometry-type"], types, true, false];
map.addLayer({ id: "layer-fill", type: "fill", source: "layer", filter: isType("Polygon", "MultiPolygon"), paint: { "fill-color": "#2f7d5b", "fill-opacity": 0.35 } });
map.addLayer({ id: "layer-line", type: "line", source: "layer", filter: isType("Polygon", "MultiPolygon", "LineString", "MultiLineString"), paint: { "line-color": "#1d4f3a", "line-width": 1.5 } });
map.addLayer({ id: "layer-point", type: "circle", source: "layer", filter: isType("Point", "MultiPoint"), paint: { "circle-radius": 4, "circle-color": "#2f7d5b", "circle-stroke-color": "#ffffff", "circle-stroke-width": 1 } });
const layerIds = ["layer-fill", "layer-line", "layer-point"];
const basemap = addBasemapIfAvailable(map, "layer-fill");
for (const id of layerIds) {
map.on("click", id, (event) => {
const properties = event.features[0].properties;
const rows = Object.entries(properties)
.map(([key, value]) => `<tr><td>${escapeHtml(key)}</td><td>${escapeHtml(typeof value === "object" ? JSON.stringify(value) : value)}</td></tr>`)
.join("");
new maplibregl.Popup({ maxWidth: "320px" })
.setLngLat(event.lngLat)
.setHTML(`<strong class="popup-title">${escapeHtml(readField(properties, labelField) ?? "(no label)")}</strong><table>${rows}</table>`)
.addTo(map);
});
map.on("mouseenter", id, () => (map.getCanvas().style.cursor = "pointer"));
map.on("mouseleave", id, () => (map.getCanvas().style.cursor = ""));
}
const [minX, minY, maxX, maxY] = bboxOf(geojson.features);
map.fitBounds([[minX, minY], [maxX, maxY]], { padding: 40, duration: 0, maxZoom: 16 });
const basemapNote = await basemap;
map.triggerRepaint();
await map.once("idle");
const rendered = map.queryRenderedFeatures({ layers: layerIds }).length;
if (rendered === 0) throw new Error("The layer loaded but no feature rendered in the view.");
const elapsed = Math.round(performance.now()); // since navigation start
document.body.dataset.featureCount = String(geojson.features.length);
document.body.dataset.renderedFeatures = String(rendered);
document.body.dataset.sampleElapsedMs = String(elapsed);
status.textContent = `PASS: ${layer.name ?? service} (${layer.geometryType ?? "features"}): ${geojson.features.length} features, ${rendered} rendered in view, ${elapsed} ms. Basemap: ${basemapNote}. Click a feature for its attributes.`;
status.dataset.state = "pass";
document.body.dataset.sampleStatus = "pass";
} catch (error) {
status.textContent = `FAIL: ${error.message}`;
status.dataset.state = "fail";
document.body.dataset.sampleStatus = "fail";
document.body.dataset.sampleError = error.message;
}
</script>
</body>
</html>
This file is the one the running sample below serves, byte for byte (see the staged file digests).
Background, configuration, and troubleshooting
FeatureServer layer in MapLibre GL JS
Load a GeoServices FeatureServer layer as GeoJSON, draw it in MapLibre GL JS, fit the map to it and open a popup on click. One HTML file, no build step, no account and no API key.
| Goal | Build a map from a FeatureServer layer |
| Level | Beginner |
| Time | About 10 minutes |
| Prerequisites | A modern web browser, internet access, any static file server |
| Libraries | maplibre-gl@6.4.1 (ES module from jsDelivr, pinned) |
Run
Serve src/ with any static web server. With Node.js 20+:
<!-- doc-run: run ready-url="http://localhost:3000/" -->
npx serve src
Open http://localhost:3000. The page reads maui-place-names/FeatureServer/6 (2,328 Maui place names) from https://demo.honua.io, draws them and fits the map. Click a point for its attributes. The <pre> under the map lists every request the page made.
What the code does
GET <layer>?f=jsonreads the layer's name, geometry type andmaxRecordCount.GET <layer>/query?where=…&outFields=*&outSR=4326&f=geojsonpages through the layer (resultOffset/resultRecordCount) until the server stops flaggingexceededTransferLimit.- The FeatureCollection becomes a MapLibre
geojsonsource with fill, line and circle layers filtered by["geometry-type"], so points, lines and polygons all draw. fitBoundsuses the features' bounding box; aclickhandler opens amaplibregl.Popupwith the feature's attributes.
There are no third-party basemap tiles. If the server publishes its own Protomaps archive at /api/v1/tiles/pmtiles/maui-basemap, the page adds it under the layer; otherwise it keeps a plain background and says so.
Parameters
| Parameter | Default | Meaning |
|---|---|---|
baseUrl | https://demo.honua.io | Honua server to query. |
service | maui-place-names | FeatureServer service name. |
layer | 6 | Layer id within the service. |
where | 1=1 | Query filter. |
labelField | name | Attribute shown as the popup title; a dotted path reads a nested value. |
basemap | (probe) | none skips the PMTiles basemap probe. |
On success the page sets document.body.dataset.sampleStatus to pass, with data-feature-count, data-rendered-features and data-sample-elapsed-ms. On failure it shows FAIL: <server error message> (for example FAIL: Not Found for a missing service) and sets data-sample-status to fail.
Verification
verify/ holds the Playwright spec the sample runner executes: against a composed server with a layer seeded from the committed fixture (feature count, every polygon rendered, popup title), and against the public-live defaults. See docs/browser-verification.
- Time
- 10 minutes
- Level
- beginner
- Data
- public-live
- Auth
- anonymous
Before you run it: Modern web browser, Internet access, Any static file server (for example `npx serve src`)
Runs green — verified 2026-10-24 against server 2026.1.1