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
geopandas>=0.14- Python's built-in
http.serverfor local serving (no install) - MapLibre GL JS
4.xfrom a CDN (loaded in the HTML)
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
- Blank map, console shows a CORS or fetch error. You opened
index.htmlviafile://. Serve over HTTP from the same folder. - Points off the coast of Africa. GeoJSON still in a projected CRS; reproject to EPSG:4326.
- Page hangs on load. The GeoJSON is too large to inline — switch to Generating PMTiles from GeoParquet.
addLayerthrows "source not found". You added the layer outside themap.on("load", ...)callback.- Nothing styled by value. The property name in
["get", "daily_riders"]doesn't match the GeoJSON; check the exported columns.