Serving GeoJSON to MapLibre GL JS

This guide takes a GeoDataFrame and turns it into a working MapLibre GL JS map served as a static page — the minimal end-to-end path with no tile server or build tooling. It is for practitioners who have a vector layer in Python and want browser-grade rendering. It sits under MapLibre GL Vector Web Maps in Web Mapping & Interactive Visualization.

Why This Approach / What Goes Wrong

A GeoJSON source is the simplest thing MapLibre can render, but two details trip people up. First, MapLibre wants EPSG:4326 coordinates; a layer left in UTM lands off-globe. Second, an inline GeoJSON of more than a few megabytes makes the page hang while the browser parses it — at that point you need vector tiles, not GeoJSON. Within those bounds, serving GeoJSON is the right, dependency-light choice.

The other pitfall is silent CORS failure: if the page loads the GeoJSON from a file:// URL or a different origin, the fetch is blocked and the map is blank with a console error. Serve the page and its data from the same local HTTP server during development.

Prerequisites

conda install -c conda-forge "geopandas=0.14.*"

Step-by-Step Implementation

1. Export a lean GeoJSON source.

import geopandas as gpd

# transit_stops analysed in EPSG:32616 upstream
transit_stops = gpd.read_file("transit_stops.gpkg").to_crs(epsg=4326)
transit_stops = transit_stops[["stop_name", "daily_riders", "geometry"]]
transit_stops["geometry"] = transit_stops.geometry.set_precision(1e-6)

transit_stops.to_file("public/stops.geojson", driver="GeoJSON")

2. Write the HTML with a GeoJSON source and a data-driven layer.

html = """<!doctype html>
<html><head><meta charset="utf-8">
<script src="https://unpkg.com/maplibre-gl@4.5.0/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@4.5.0/dist/maplibre-gl.css" rel="stylesheet"/>
<style>html,body,#map{height:100%;margin:0}</style></head>
<body><div id="map"></div><script>
const map = new maplibregl.Map({
  container: "map",
  style: "https://demotiles.maplibre.org/style.json",
  center: [-87.63, 41.88], zoom: 10,
});
map.on("load", () => {
  map.addSource("stops", { type: "geojson", data: "stops.geojson" });
  map.addLayer({
    id: "stops-circles", type: "circle", source: "stops",
    paint: {
      "circle-radius": ["interpolate", ["linear"], ["get", "daily_riders"], 0, 3, 5000, 14],
      "circle-color": "#3e5c76", "circle-opacity": 0.85,
      "circle-stroke-color": "#1d2d44", "circle-stroke-width": 1,
    },
  });
});
</script></body></html>"""

with open("public/index.html", "w", encoding="utf-8") as fh:
    fh.write(html)

3. Serve the folder so the page and GeoJSON share an origin.

# From the project root:
#   python -m http.server 8000 --directory public
# then open http://localhost:8000

Verification

Confirm the source is valid and same-origin before debugging styling.

import json

with open("public/stops.geojson", encoding="utf-8") as fh:
    fc = json.load(fh)

assert fc["type"] == "FeatureCollection"
print(f"Features: {len(fc['features'])}")          # Features: 1423
# Spot-check coordinates are lon/lat in plausible range
lon, lat = fc["features"][0]["geometry"]["coordinates"]
assert -180 <= lon <= 180 and -90 <= lat <= 90     # passes for EPSG:4326
print(f"First stop at lon={lon:.4f}, lat={lat:.4f}")

In the browser console, map.getSource("stops") should return the source object once loaded; undefined means the addSource call ran before the load event.

Edge Cases & Debugging