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:

  • python 3.10 or later, isolated in a venv or conda environment
  • geopandas 0.14+, pyproj 3.6+, shapely 2.0+
  • pydeck 0.8+ or folium 0.15+ (or both, for comparison)
  • fastapi 0.110+ and uvicorn for 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, or Shapefile format 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.


Static vs Dynamic Map Export Architecture Two parallel pipeline diagrams. Left: Python data pipeline feeds PyDeck/Folium which outputs a self-contained HTML file served from a CDN to the browser. Right: Python data pipeline feeds a FastAPI service which streams GeoJSON to a browser-side map shell that renders it dynamically. Static Export Python pipeline (geopandas · validation · CRS fix) PyDeck / Folium export (serialize GeoJSON → HTML) CDN / Object Storage (Cache-Control: immutable) Browser renders (zero network requests after load) Dynamic Export Python pipeline (geopandas · PostGIS · spatial index) FastAPI service (GeoJSON endpoint · CORS · auth) Reverse proxy / API gateway (rate limit · JWT · connection pool) Browser fetches on demand (AbortController · Cache API · retry)

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 (asyncpg with a pool size matched to your vCPU count)
  • Response compression (gzip or br) — 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:

  1. Check the browser console for WebGL context errors or 404s for CDN-hosted scripts.
  2. Confirm the payload size with du -sh output_map.html — aim for under 10 MB.
  3. 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 the js_urls parameter.
  • TopoJSON is not natively supported: Leaflet and deck.gl consume GeoJSON. If you pre-encode to TopoJSON for smaller payloads, you must include topojson-client in 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). pyproj 3.x respects the CRS axis order by default — always confirm with gdf.crs.axis_info and force always_xy=True in the Transformer if needed.
  • iframe sandbox attribute: If you embed a static export inside a sandboxed <iframe>, the allow-scripts token 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 with gdf.to_file("/dev/stdout", driver="GeoJSON") piped to a streaming writer, or switch to the dynamic export path.