← All samples

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
EXACT STAGED SOURCEhonua-samples @ 225430937e71
Full tree ↗
<!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).

Open full screen ↗ Built from honua-samples @225430937e71, public-live mode against demo.honua.io · 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.

GoalBuild a map from a FeatureServer layer
LevelBeginner
TimeAbout 10 minutes
PrerequisitesA modern web browser, internet access, any static file server
Librariesmaplibre-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

  1. GET <layer>?f=json reads the layer's name, geometry type and maxRecordCount.
  2. GET <layer>/query?where=…&outFields=*&outSR=4326&f=geojson pages through the layer (resultOffset/resultRecordCount) until the server stops flagging exceededTransferLimit.
  3. The FeatureCollection becomes a MapLibre geojson source with fill, line and circle layers filtered by ["geometry-type"], so points, lines and polygons all draw.
  4. fitBounds uses the features' bounding box; a click handler opens a maplibregl.Popup with 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

ParameterDefaultMeaning
baseUrlhttps://demo.honua.ioHonua server to query.
servicemaui-place-namesFeatureServer service name.
layer6Layer id within the service.
where1=1Query filter.
labelFieldnameAttribute 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