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.

GeoDataFrame to MapLibre GL JS map, served same-origin A five-stage data-flow pipeline. A GeoDataFrame in projected EPSG:32616 is reprojected with to_crs(4326) to longitude/latitude, written with to_file to stops.geojson inside a public folder, served by Python's http.server on localhost:8000, then fetched by MapLibre GL JS in the browser which calls addSource and addLayer to draw one circle per feature. A callout warns that all metric work must finish before the reproject step; a second callout notes that because the page and its data share one origin there is no CORS block. Python · process & serve Browser · MapLibre render Buffers, areas, joins in metres must finish BEFORE this reproject Page + data from one origin same origin → no CORS block GeoDataFrame EPSG:32616 · metric .to_crs(4326) reproject → lon/lat .to_file() public/stops.geojson http.server :8000 serves public/ MapLibre GL JS addSource + addLayer HTTP fetch One circle per feature circle-radius interpolated from the daily_riders property — a data-driven style
GeoDataFrame to MapLibre GL JS map, served same-origin

Prerequisites

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.

Coordinate digits kept and discarded by set_precision, and the file size that follows A single coordinate pair is shown twice. Straight from GeoPandas it is written with fifteen decimal places per ordinate and occupies 41 bytes, with the nine digits past the sixth decimal highlighted as sub-millimetre noise no map can draw. After set_precision at one times ten to the minus six it is written with six decimals and occupies 23 bytes, a 44 percent reduction, and six decimals still resolve about eleven centimetres at the equator. Two proportional bars below show the effect on the whole export of 1,423 transit stops: 1.42 megabytes raw against 0.92 megabytes rounded, a 35 percent saving. Which coordinate digits are worth shipping float64 straight from GeoPandas [-87.642137 492916318 , 41.850329 471882045 ] 41 bytes per coordinate pair Digits past the sixth decimal encode sub-millimetre noise no map can draw after set_precision(1e-6) [-87.642137, 41.850329] 23 bytes per coordinate pair · 44% fewer Six decimals resolve about 11 cm finer than a screen pixel at zoom 18 Exported file size · 1,423 transit stops stops.geojson · raw float64 · 1.42 MB after set_precision · 0.92 MB · 35% smaller
Rounding costs nothing visible because six decimals already resolve finer than any rendered pixel, yet it strips nearly half the characters from every coordinate in the file.

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.

Map startup timeline and the only safe moment to add a source A left-to-right timeline of four map startup events. First the Map constructor runs, attaching the container and requesting the style, while the sources map is still empty. Second the style JSON arrives but no layers have been added. Third the load event fires and the style and glyphs are ready. Fourth the first paint puts circles on screen. A panel above the third event marks it as the only correct place to call addSource and addLayer. A panel below the first event shows what happens when those calls run right after the constructor instead: an error that the style is not done loading, or source not found on addLayer. A final note suggests checking map.getSource in the console once the map has loaded. The startup timeline, and the one safe moment to add a source Correct: inside the load callback map.addSource(...); map.addLayer(...) time new maplibregl.Map() style requested, sources empty style JSON arrives style ready, layers not added 'load' event fires style + glyphs ready first paint circles on screen Too early: right after the constructor Error: Style is not done loading Check in the console once loaded: map.getSource("stops")
The style arrives asynchronously, so every 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

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.