Static vs Dynamic Map Export Methods
Part of the Python-to-Web Generation Workflows guide.
Choosing how a Python geospatial pipeline hands off its output to a browser is one of the most consequential architectural decisions in dashboard engineering. The choice determines payload size, infrastructure overhead, the ceiling for user interactivity, and how quickly data changes reach end users. This guide covers both self-contained HTML exports — where data is baked in at build time — and API-backed maps that fetch live data at runtime, so frontend developers, GIS analysts, and agency teams can match the right strategy to their deployment context.
Prerequisites
Before implementing either export method, confirm your environment meets these requirements:
python3.10 or later, isolated in avenvorcondaenvironmentgeopandas0.14+,pyproj3.6+,shapely2.0+pydeck0.8+ orfolium0.15+ (or both, for comparison)fastapi0.110+ anduvicornfor dynamic API endpoints- A CDN or object-storage bucket (S3, GCS, or R2) with public read access for static artefacts
- A reverse proxy (Nginx, Caddy) or API gateway for dynamic deployments
- Source data in
GeoJSON,Parquet, orShapefileformat with a known coordinate reference system
Confirm that the correct CRS & Projection Management has been applied to your source data before writing any export code — misaligned projections propagate silently and cause rendering failures that are difficult to debug once the artefact is deployed.
The Core Distinction
The two approaches differ on when and where data binding and rendering occur.
| Characteristic | Static Export | Dynamic Export |
|---|---|---|
| Data binding | Compile-time — baked into the HTML payload | Runtime — fetched from an API or tile service |
| Initial payload | Larger (data travels with the page) | Smaller (config shell only) |
| Post-load requests | None — fully self-contained | Each layer change triggers a fetch |
| Interactivity ceiling | Pre-baked state transitions | Full client-side filtering, live feeds, multi-user |
| Infrastructure | CDN or object storage only | API gateway, database, optional tile server |
| Best fit | Archival reports, offline distribution, fixed datasets | Real-time tracking, live telemetry, collaborative dashboards |
Neither approach is universally superior. A high-frequency sensor feed demands a dynamic endpoint. A quarterly planning map destined for a PDF-to-web conversion is a poor candidate for a live API.
Step 1 — Data Preparation & Validation
Geospatial pipelines fail most often at the ingestion layer. Standardise your CRS to EPSG:4326 (WGS 84) before any export operation, since every web mapping library expects longitude/latitude coordinates.
import geopandas as gpd
from shapely.validation import make_valid
def prepare_geodata(input_path: str, target_crs: str = "EPSG:4326") -> gpd.GeoDataFrame:
"""Load, repair, and reproject a vector dataset for web export."""
gdf = gpd.read_file(input_path)
# Repair topology errors before any CRS transformation
gdf["geometry"] = gdf["geometry"].apply(
lambda geom: make_valid(geom) if geom is not None else None
)
gdf = gdf.dropna(subset=["geometry"])
if gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(target_crs)
# Validate bounding box — out-of-range coordinates crash WebGL renderers
bounds = gdf.total_bounds # [minx, miny, maxx, maxy]
assert -180 <= bounds[0] <= 180 and -90 <= bounds[1] <= 90, (
f"Bounding box out of WGS84 range: {bounds}"
)
return gdf
The is_valid check from geopandas.GeoSeries flags self-intersections and ring-orientation errors that survive file conversion. Run it before you invest build time in the export step.
Step 2 — Building the Static Export Pipeline
Static exports are the right choice for compliance reporting, public-facing reference maps, embedded portal widgets, and any environment with strict network isolation. The entire dataset is serialised into the HTML payload, which eliminates external HTTP requests during rendering and makes the file fully self-contained.
PyDeck standalone HTML
pydeck’s to_html() method embeds deck.gl, the layer configuration, and the GeoJSON data into a single file. By default it references a CDN-hosted deck.gl bundle; pass as_string=False if you need a fully offline artefact (see the offline note in gotchas below).
import json
import pydeck as pdk
def export_static_pydeck(gdf: gpd.GeoDataFrame, output_path: str) -> None:
"""Write a self-contained deck.gl HTML file from a GeoDataFrame."""
# Simplify geometry to keep payload under 10 MB
gdf = gdf.copy()
gdf["geometry"] = gdf["geometry"].simplify(tolerance=0.0001, preserve_topology=True)
layer = pdk.Layer(
"GeoJsonLayer",
data=json.loads(gdf.to_json()),
get_fill_color=[255, 100, 50, 160],
get_line_color=[255, 255, 255, 200],
pickable=True,
auto_highlight=True,
)
centroid_lat = float(gdf.geometry.centroid.y.mean())
centroid_lon = float(gdf.geometry.centroid.x.mean())
view_state = pdk.ViewState(latitude=centroid_lat, longitude=centroid_lon, zoom=8)
deck = pdk.Deck(
layers=[layer],
initial_view_state=view_state,
map_provider="carto", # no Mapbox token required
)
deck.to_html(output_path, notebook_display=False)
For deeper control of the generated markup — injecting custom CSS, adding analytics tags, or wiring in a legend — see Exporting PyDeck visualizations to standalone HTML.
Folium static export
folium builds on Leaflet.js and offers a gentler learning curve for teams already using the Python data-science ecosystem.
import folium
def export_static_folium(gdf: gpd.GeoDataFrame, output_path: str) -> None:
"""Write a self-contained Leaflet HTML file from a GeoDataFrame."""
center = [gdf.geometry.centroid.y.mean(), gdf.geometry.centroid.x.mean()]
m = folium.Map(location=center, zoom_start=9, tiles="CartoDB positron")
folium.GeoJson(
gdf.__geo_interface__,
style_function=lambda feature: {
"fillColor": "#e05c2e",
"color": "#ffffff",
"weight": 1,
"fillOpacity": 0.65,
},
tooltip=folium.GeoJsonTooltip(fields=list(gdf.columns.difference(["geometry"]))),
).add_to(m)
m.save(output_path)
When embedding Folium exports inside React dashboards or other single-page applications, the iframe sandboxing approach in Iframe Embedding & Isolation prevents CSS and script leakage across the frame boundary.
Step 3 — Architecting the Dynamic Export Pipeline
Dynamic exports decouple the map shell from the underlying data. The browser loads a lightweight configuration, then queries an endpoint for feature collections, vector tiles, or streaming updates. This model aligns with OGC API - Features interoperability standards.
FastAPI GeoJSON endpoint
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import geopandas as gpd
app = FastAPI(title="Geo Export API")
app.add_middleware(
CORSMiddleware,
allow_origins=["https://your-dashboard-origin.com"],
allow_methods=["GET"],
allow_headers=["*"],
)
# In production, use a spatially indexed PostGIS table or a pre-loaded cache.
# Shown here as a module-level dict for clarity.
_dataset_cache: dict[str, gpd.GeoDataFrame] = {}
def load_dataset(dataset_id: str, path: str) -> None:
gdf = prepare_geodata(path) # the validation function from Step 1
_dataset_cache[dataset_id] = gdf
@app.get("/api/features/{dataset_id}")
def get_features(
dataset_id: str,
bbox: str | None = Query(default=None, description="minx,miny,maxx,maxy in EPSG:4326"),
) -> JSONResponse:
if dataset_id not in _dataset_cache:
raise HTTPException(status_code=404, detail="Dataset not found")
gdf = _dataset_cache[dataset_id]
if bbox:
minx, miny, maxx, maxy = (float(v) for v in bbox.split(","))
gdf = gdf.cx[minx:maxx, miny:maxy] # spatial index slice
return JSONResponse(
content={"type": "FeatureCollection", "features": gdf.__geo_interface__["features"]},
media_type="application/geo+json",
)
The bbox parameter implements server-side spatial filtering so the client only receives features inside the current viewport — essential when datasets contain hundreds of thousands of geometries. Pair this with a spatial index on the source table (PostGIS GIST or a shapely STRtree) to keep response times under 200 ms.
Frontend fetch pattern
async function loadLayer(datasetId, map) {
const bbox = map.getBounds();
const bboxParam = [
bbox.getWest(), bbox.getSouth(), bbox.getEast(), bbox.getNorth()
].join(",");
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(
`/api/features/${datasetId}?bbox=${bboxParam}`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const geojson = await response.json();
map.getSource(datasetId).setData(geojson);
} catch (err) {
if (err.name !== "AbortError") {
console.error("Layer fetch failed:", err);
// Implement exponential backoff before retrying
}
} finally {
clearTimeout(timeoutId);
}
}
Use AbortController to cancel in-flight requests when the user pans quickly — without it, stale responses can overwrite a newer layer state and produce a confusing jump.
Step 4 — Deployment & Integration
Static deployment
Serve static HTML files from object storage or a CDN. Set Cache-Control: public, max-age=31536000, immutable on versioned filenames (e.g. map_v2.4.1.html). When you need the Tile vs Vector Rendering Strategies decision to propagate to a new static export, regenerate the file and update the reference in your template — the old versioned URL can stay on the CDN indefinitely.
For responsive behaviour inside HTML pages, use CSS aspect-ratio on the <iframe> or map <div> and wire a ResizeObserver to call map.resize() (MapLibre/Mapbox) or invalidateSize() (Leaflet) when the container changes. A deeper treatment of grid and breakpoint patterns is in Responsive Dashboard Layouts.
Dynamic deployment
Run the FastAPI service behind Nginx or a cloud load balancer with:
- Rate limiting (e.g. 100 requests/minute per IP) to protect against accidental crawl loops
- JWT validation on any endpoint that serves sensitive geometries
- Connection pooling to the database (
asyncpgwith a pool size matched to your vCPU count) - Response compression (
gziporbr) — GeoJSON compresses well (60–80% reduction)
Tag dynamic endpoints with a timestamp query parameter (/api/features/zones?ts=1750682400) to bypass CDN caches during deployments. Keep in mind that Cache Invalidation Strategies apply at the API gateway layer as well as the browser.
Verification & Smoke-Test
After generating a static export, open it in a browser and:
- Check the browser console for WebGL context errors or 404s for CDN-hosted scripts.
- Confirm the payload size with
du -sh output_map.html— aim for under 10 MB. - Open DevTools > Network and verify no requests fire after the page finishes loading (static export).
For a dynamic endpoint:
# Confirm the endpoint returns valid GeoJSON
curl -s "http://localhost:8000/api/features/zones?bbox=-74.1,40.6,-73.9,40.8" \
| python3 -m json.tool | head -30
# Check CORS headers
curl -I -H "Origin: https://your-dashboard-origin.com" \
"http://localhost:8000/api/features/zones"
# Expect: Access-Control-Allow-Origin: https://your-dashboard-origin.com
Run python3 -c "import geopandas as gpd; gpd.read_file('output.geojson').plot()" as a quick sanity check that the serialised geometry round-trips cleanly.
Troubleshooting
Why does my PyDeck static export show a blank map?
A missing or invalid Mapbox API token prevents the base layer from loading. Switch to a token-free provider by passing map_provider="carto" to pdk.Deck(), or set the MAPBOX_API_KEY environment variable. If the base layer loads but feature geometries are absent, confirm that gdf.to_json() does not return an empty FeatureCollection — add an assertion on len(gdf) before the export call.
My FastAPI GeoJSON endpoint returns data but the map stays empty.
This is almost always a CORS misconfiguration. Confirm that CORSMiddleware allow_origins includes the exact origin of your frontend (protocol, hostname, and port). Also verify the response Content-Type is application/geo+json or application/json — some map libraries refuse other MIME types.
Static export HTML exceeds 20 MB — what should I do?
Apply geometry simplification with gdf.simplify(tolerance=0.0001, preserve_topology=True) before serialising. For polygon-heavy datasets, encode as TopoJSON to reduce coordinate redundancy by 40–60%. If the dataset is inherently dense, switch to a dynamic endpoint that filters features to the visible viewport.
How do I invalidate a static HTML export after a data update?
Append a content hash or ISO timestamp to the filename (e.g. map_20260623T1200Z.html) and update the reference in your template. Set Cache-Control: public, max-age=31536000, immutable on versioned files so the CDN caches them aggressively; old URLs naturally expire once you stop linking to them.
The Folium save() method drops my custom JavaScript — why?
Folium’s save() renders the Jinja2 template once and does not preserve script elements injected dynamically after map creation. Attach custom JS before calling save() using folium.Element(), or post-process the HTML with BeautifulSoup to inject scripts into the <head> after the fact.
Gotchas & Edge Cases
- PyDeck offline bundles:
deck.to_html()references deck.gl from a CDN by default. In air-gapped environments, host the bundle locally and pass its URL to thejs_urlsparameter. - TopoJSON is not natively supported: Leaflet and deck.gl consume GeoJSON. If you pre-encode to TopoJSON for smaller payloads, you must include
topojson-clientin the page and convert back to GeoJSON before passing to the map layer. - Axis-order pitfall: Some older Shapefile sources store coordinates as
(latitude, longitude)rather than(longitude, latitude).pyproj3.x respects the CRS axis order by default — always confirm withgdf.crs.axis_infoand forcealways_xy=Truein theTransformerif needed. - iframe
sandboxattribute: If you embed a static export inside a sandboxed<iframe>, theallow-scriptstoken is required for the map to initialise. Missing tokens cause a silent no-op with no console error in the host page. See Iframe Embedding & Isolation for the full attribute matrix. - Scheduled rebuild side effects: If a Scheduled Map Rebuild Workflows job regenerates a static export file in-place (overwriting the path), CDN edge nodes may serve the old cached version for up to 24 hours. Versioned filenames avoid this entirely.
- WebGL context limit: Browsers cap the number of concurrent WebGL contexts at 8–16. Embedding multiple dynamic maps on a single page can exhaust this limit. Destroy unused map instances explicitly with
map.remove()(MapLibre) before mounting a replacement. - Large GeoDataFrame serialisation:
gdf.to_json()holds the entire JSON string in memory simultaneously. For datasets over 500 MB, serialise incrementally withgdf.to_file("/dev/stdout", driver="GeoJSON")piped to a streaming writer, or switch to the dynamic export path.
Related
- Python-to-Web Generation Workflows — parent guide
- Exporting PyDeck Visualizations to Standalone HTML — deep-dive on PyDeck HTML customisation
- Iframe Embedding & Isolation — secure cross-origin embedding of generated maps
- Responsive Dashboard Layouts — fluid grid and breakpoint patterns for map containers
- Cache Invalidation Strategies — keeping CDN and browser caches in sync after data updates
- Scheduled Map Rebuild Workflows — automating static export regeneration on a cron schedule