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 and no JavaScript build tooling. It is for practitioners who already have a vector layer in Python and want browser-grade rendering without adopting a front-end stack. It sits under MapLibre GL Vector Web Maps in Web Mapping & Interactive Visualization, and it is the step before you outgrow inline GeoJSON and move to Generating PMTiles from GeoParquet.
Why This Approach / What Goes Wrong
A GeoJSON source is the simplest thing MapLibre can render: point it at a URL or an inline object, add a layer that references it, and the client parses the whole FeatureCollection into memory and draws it. There is no tiling step, no server-side index, and no build. That simplicity is exactly why it is the right first choice for anything up to a few thousand features — and exactly why three details quietly break it.
First, MapLibre expects EPSG:4326 longitude/latitude in every source, because internally it works in Web Mercator and assumes your input is unprojected WGS 84. A layer still carrying a projected CRS — a UTM zone, a state plane, or a national grid — is interpreted as raw degrees, so a dataset in metres (coordinates in the hundreds of thousands) lands far off the globe near [0, 0] in the Gulf of Guinea. Do all metric work (buffers, areas, joins) in the projected CRS first, then reproject to 4326 as the last step before export; the reprojection pipeline is the place to get the axis order and datum right, not the browser.
Second, an inline GeoJSON larger than a few megabytes makes the page hang while the browser parses and holds it in memory. GeoJSON is verbose text; a coordinate-dense polygon layer balloons fast. Past roughly 5–10 MB you have crossed the ceiling where GeoJSON stops being the right tool and vector tiles begin — the PMTiles pipeline exists precisely for that regime. Within those bounds, serving GeoJSON is the correct, dependency-light choice.
Third, and most common in practice, is silent CORS failure. If the page loads its GeoJSON from a file:// URL or from a different origin than the page itself, the browser blocks the fetch and you get a blank map with only a console error to show for it. The map container renders, the basemap may even load, but your layer never appears. The fix is not code — it is serving the page and its data from the same local HTTP origin during development.
Prerequisites
geopandas>=0.14— reproject and export the sourceshapely>=2.0— providesset_precisionfor coordinate rounding- Python's built-in
http.server— local same-origin serving (no install) - MapLibre GL JS
4.xfrom a CDN (loaded in the HTML, nothing topip install)
conda install -c conda-forge "geopandas=0.14.*" "shapely=2.0.*"
Step-by-Step Implementation
The worked example publishes a transit-stops point layer, sized by daily ridership, on top of the MapLibre demo basemap. The upstream analysis was done in EPSG:32616 (UTM zone 16N, metric) so distances and catchment areas were correct; the map only needs the geometry reprojected to lon/lat.
1. Export a lean GeoJSON source. Reproject to 4326, drop every column the map will not read, and round coordinates so the file stays small.
import geopandas as gpd
# transit_stops analysed in EPSG:32616 (metric) upstream
transit_stops = gpd.read_file("transit_stops.gpkg").to_crs(epsg=4326)
# Keep only what the layer styles or labels with — every extra column is bytes shipped to the client
transit_stops = transit_stops[["stop_name", "daily_riders", "geometry"]]
# 1e-6 degrees ~ 0.11 m at the equator: plenty of precision, far fewer digits per coordinate
transit_stops["geometry"] = transit_stops.geometry.set_precision(1e-6)
transit_stops.to_file("public/stops.geojson", driver="GeoJSON")
Six decimal places of longitude/latitude resolve to roughly 10 cm — more than any web map needs — while the raw float64 output GeoPandas produces by default can carry fifteen. Rounding with set_precision before export routinely trims 20–40% off the file with no visible difference.
2. Write the HTML with a GeoJSON source and a data-driven layer. MapLibre reads the FeatureCollection from the URL and draws one circle per feature, with the radius interpolated from the daily_riders property.
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, // lon, lat — note the order
});
map.on("load", () => {
map.addSource("stops", { type: "geojson", data: "stops.geojson" });
map.addLayer({
id: "stops-circles", type: "circle", source: "stops",
paint: {
// interpolate radius from a feature property — this is a data-driven style
"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)
Two ordering rules bite here. The map center is [lon, lat] — the same axis convention as GeoJSON and the opposite of the (lat, lon) that many APIs and PyProj transformers hand back — so a transposed pair silently centres the map in the wrong hemisphere. And every addSource/addLayer call must live inside the map.on("load", ...) callback, because the style is not ready before the load event fires.
addSource and addLayer call has exactly one valid slot on this timeline — after the load event, never beside the constructor.3. Serve the folder so the page and GeoJSON share an origin. Run the built-in server rooted at the public/ directory and open the printed URL.
# From the project root:
# python -m http.server 8000 --directory public
# then open http://localhost:8000
Because both index.html and stops.geojson are served from http://localhost:8000, the fetch is same-origin and the CORS check never fires. Opening public/index.html by double-clicking it — a file:// URL — is the single most common reason this recipe produces a blank map.
One thing http.server will not do for you is compress the response, and GeoJSON is the most compressible payload in the geospatial world: repeated key names, repeated digit patterns, and no binary content at all. The same 0.92 MB export usually leaves the wire at 150–200 KB once gzip is applied, so measure the compressed size rather than the file size when deciding whether you are still inside the GeoJSON regime:
import gzip
from pathlib import Path
raw = Path("public/stops.geojson").read_bytes()
print(f"on disk {len(raw) / 1e6:.2f} MB") # on disk 0.92 MB
print(f"gzipped {len(gzip.compress(raw, 6)) / 1e6:.2f} MB") # gzipped 0.17 MB
Any production static host — nginx, Caddy, S3 behind a CDN, or the deployment described in Deploying a Python Map App Behind a CDN — will do this automatically for application/geo+json and application/json. Confirm it rather than assume it: a curl -sI -H 'Accept-Encoding: gzip' that comes back without Content-Encoding: gzip means every visitor is downloading five times more than they need to.
4. Add a click popup so the map answers questions. A static circle layer is a picture; two event handlers make it a tool. MapLibre passes the clicked feature's properties straight through, so anything you kept in step 1 is available here — and anything you dropped is not.
interaction_js = """
map.on("click", "stops-circles", (event) => {
const stop = event.features[0];
new maplibregl.Popup()
.setLngLat(stop.geometry.coordinates)
.setHTML(`<b>${stop.properties.stop_name}</b><br>` +
`${stop.properties.daily_riders.toLocaleString()} riders/day`)
.addTo(map);
});
// A pointer cursor is the only affordance telling users the circles are clickable
map.on("mouseenter", "stops-circles", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "stops-circles", () => { map.getCanvas().style.cursor = ""; });
"""
Two constraints follow from the fact that the popup reads event.features[0]. Only layers you name in the handler produce hits, so a click that lands on overlapping circles returns the topmost rendered feature and nothing else. And the coordinates used to anchor the popup come from the feature geometry, which is exact for points but not for polygons — for a polygon layer use event.lngLat (where the user actually clicked) instead of the geometry, or the popup will jump to the first vertex of the ring.
Verification
Confirm the source is valid, unprojected, and same-origin before you start debugging paint properties — most "my map is blank" reports are a bad source, not a bad style.
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 the first coordinate is lon/lat in a plausible range (not projected metres)
lon, lat = fc["features"][0]["geometry"]["coordinates"]
assert -180 <= lon <= 180 and -90 <= lat <= 90 # passes for EPSG:4326; fails for UTM
print(f"First stop at lon={lon:.4f}, lat={lat:.4f}") # First stop at lon=-87.6421, lat=41.8503
If the assertion on coordinate range fails, your export skipped the reprojection and the file still holds projected metres. In the browser, open the console once the map has loaded and run map.getSource("stops"): it should return the source object. undefined means the addSource call executed before the load event — move it inside the callback.
Two further assertions catch failures that only appear in the browser. The first is strict JSON validity: Python's json.load accepts NaN, Infinity and -Infinity as extensions, while the browser's JSON.parse rejects all three outright, so a file that reads back cleanly in Python can still fail to load in MapLibre. The second is a type check on the property your paint expression reads — a daily_riders column that survived as text styles every circle identically.
with open("public/stops.geojson", encoding="utf-8") as fh:
strict = json.load(fh, parse_constant=lambda c: (_ for _ in ()).throw(
ValueError(f"{c} is not valid JSON — the browser will refuse this file")))
riders = [f["properties"]["daily_riders"] for f in strict["features"]]
assert all(isinstance(v, (int, float)) for v in riders), "daily_riders is not numeric"
print(f"riders {min(riders)}–{max(riders)}, all numeric") # riders 12–4820, all numeric
parse_constant fires only for those three literals, which makes it a one-line guard against the most common invalid-JSON export. If it raises, the culprit is almost always a missing value that went out as NaN: use GeoDataFrame.to_json(na="null") (the default) rather than na="keep", or fill the column before exporting.
Edge Cases & Debugging
- Blank map, console shows a CORS or fetch error. You opened
index.htmlviafile://. Serve over HTTP from the same folder withhttp.server. - Points land off the coast of Africa near
[0, 0]. The GeoJSON is still in a projected CRS; reproject with.to_crs(epsg=4326)before export. - Map centred in the wrong place with no error. The
centerarray is[lat, lon]instead of[lon, lat]; swap the pair. - Page hangs or the tab freezes on load. The GeoJSON is too large to inline — switch to Generating PMTiles from GeoParquet and let the client fetch only the byte ranges in view.
addLayerthrows "source not found" or "style is not done loading". The call ran outside themap.on("load", ...)callback; nest all source and layer setup inside it.- Nothing is styled by value — every circle is the same size. The property name in
["get", "daily_riders"]does not match a column in the exported GeoJSON; check the columns you kept in step 1. SyntaxError: Unexpected token N in JSONin the console. A missing value was written as the literalNaN, which is valid to Python and invalid to the browser. Export withna="null"or fill the column first.TypeError: Object of type Timestamp is not JSON serializableonto_json(). Datetime columns do not survive the encoder; cast them with.dt.strftime("%Y-%m-%d")before exporting, or write withto_file, whose GeoJSON driver emits ISO strings.- Some features render and others do not. The layer draws one geometry family: a
circlelayer ignores polygons entirely. Split mixed sources into one layer per type, or add"filter": ["==", ["geometry-type"], "Point"]to each layer. - Popups fire on empty space or the wrong layer. The handler is bound to the map rather than to a layer id; pass the layer id as the second argument to
map.on("click", ...)so only that layer's features are hit-tested. - The file loads but the map is empty at the starting view. The data is somewhere else entirely — compute
gdf.total_boundsand callmap.fitBoundsinstead of trusting a hard-coded centre.
Frequently Asked Questions
How large can the GeoJSON get before I have to switch to tiles?
Judge it by the compressed transfer size and the vertex count, not the row count. Below roughly 2 MB gzipped the page loads without a visible stall on a mid-range laptop; between 2 and 5 MB you will see a parse pause on first load but panning stays smooth; past that the tab holds the entire FeatureCollection plus the renderer's internal tiles in memory and interaction starts to stutter. Dense polygons cross the line long before points do, because it is vertices that cost. When you get there, the archive path in Generating PMTiles from GeoParquet changes the transfer from one big download into a handful of small range reads.
Can I load the GeoJSON from a different domain than the page?
Yes, provided that domain sends Access-Control-Allow-Origin on the response — this is a server configuration question, not something the map can work around. Object stores expose it as a CORS policy you attach to the bucket. Until it is set the request fails at the browser before MapLibre sees a byte, which is why the console error mentions CORS and the map says nothing at all. During development, serving page and data from one origin sidesteps the whole question.
Should I inline the GeoJSON into the HTML instead of fetching it?
For anything under a few hundred kilobytes, inlining is a legitimate simplification: data accepts a GeoJSON object as readily as a URL, the file becomes a single self-contained artifact you can email, and CORS becomes irrelevant. The costs are that the map cannot start painting until the whole document has parsed, the browser cannot cache the data separately from the page, and you lose the ability to refresh the layer without reloading. Fetching from a URL is the better default the moment the data changes on a different schedule than the page.
How do I refresh the layer when the underlying data changes?
Call map.getSource("stops").setData("stops.geojson?v=" + Date.now()) on a timer or in response to an event. Replacing the data leaves the layer, its paint expressions, and the user's current viewport untouched, so the map updates without flickering back to the starting view. The cache-busting query string matters — without it a browser that cached the file aggressively will happily re-serve the old copy.