Exporting Folium Maps to Static HTML with Embedded Assets

Part of the Static vs Dynamic Export Methods guide.

Operative rule: a truly self-contained export has zero external runtime dependencies — every script, stylesheet, data payload, and tile it needs is embedded in the .html file itself, so it renders identically with the network unplugged.

How the Folium Export Works

folium.Map.save() renders a Jinja2 template into a complete HTML document. That document is not empty of dependencies: by default it contains <script> and <link> tags pointing at CDN-hosted builds of Leaflet, jQuery, Bootstrap, and Font Awesome, and each TileLayer becomes a Leaflet layer whose URL template points at a remote tile server. Open the file with no network and you get an unstyled shell, missing controls, and a grid of blank tiles. The map object is correct; its dependencies are simply somewhere else.

Making the artifact self-contained means resolving three distinct classes of dependency at export time rather than at open time. Library assets (Leaflet JS/CSS and the control libraries) are fetched once during the build and rewritten from remote <script src> / <link href> tags into inline <script> and <style> blocks. Vector data is embedded by passing parsed GeoJSON objects — not URLs — to folium.GeoJson, which serializes them into the page as JavaScript literals. Basemap tiles are the hard case: they are inherently many files, so you either omit the basemap, point the TileLayer at a locally bundled tile directory, or base64-embed a small pre-rendered tile set. This is the same trade-off surface as exporting PyDeck visualizations to standalone HTML, and it sits at the heart of the static vs dynamic export methods decision.

Inlining Folium's external assets into a self-contained file The default save output links Leaflet JS, CSS, and remote tiles. A post-processing step fetches and inlines the library assets, embeds the GeoJSON, and rewrites tiles, producing one HTML file with no external runtime dependencies. m.save() default link: leaflet.css (CDN) script: leaflet.js (CDN) GeoJSON: fetch(url) tiles: remote server needs network to render inline post-process self-contained .html style: leaflet.css inline script: leaflet.js inline GeoJSON: literal in JS tiles: bundled / base64 renders with no network 0 external runtime deps

Production-Ready Implementation

The routine below builds a Folium map with embedded GeoJSON, saves it, and then post-processes the HTML to inline the Leaflet library assets. It fetches each CDN asset once, substitutes the remote reference with an inline block, and writes a single file. The embed_geojson step passes a parsed object (never a URL) so the vector data serializes into the page. Basemap handling is explicit: pass tiles=None for a data-only map, or a local tile template for a bundled basemap.

from __future__ import annotations

import re
from pathlib import Path
from urllib.request import urlopen

import folium


def fetch(url: str) -> str:
    """Fetch a text asset once, at build time, for inlining."""
    with urlopen(url, timeout=30) as resp:  # build-time only, not runtime
        return resp.read().decode("utf-8")


def build_map(geojson_obj: dict, center: list[float]) -> folium.Map:
    """Assemble a Folium map with embedded (not fetched) GeoJSON."""
    # tiles=None yields a data-only canvas with no remote basemap.
    m = folium.Map(location=center, zoom_start=11, tiles=None)

    # Passing the parsed dict (not a URL) inlines the data as a JS literal.
    folium.GeoJson(
        geojson_obj,
        name="regions",
        style_function=lambda _f: {"color": "#2b6", "weight": 1.5},
    ).add_to(m)

    folium.LayerControl().add_to(m)
    return m


def inline_cdn_assets(html: str) -> str:
    """Rewrite external <link>/<script> CDN tags into inline blocks."""
    # Inline stylesheets: <link rel="stylesheet" href="https://.../x.css">
    def _css(match: re.Match[str]) -> str:
        href = match.group(1)
        return f"<style>/* inlined {href} */\n{fetch(href)}</style>"

    html = re.sub(
        r'<link[^>]+href="(https://[^"]+\.css)"[^>]*>',
        _css,
        html,
    )

    # Inline scripts: <script src="https://.../x.js"></script>
    def _js(match: re.Match[str]) -> str:
        src = match.group(1)
        return f"<script>/* inlined {src} */\n{fetch(src)}</script>"

    html = re.sub(
        r'<script[^>]+src="(https://[^"]+\.js)"[^>]*>\s*</script>',
        _js,
        html,
    )
    return html


def export_selfcontained(
    geojson_obj: dict,
    center: list[float],
    out_path: Path,
) -> Path:
    """Save a Folium map and inline every external asset it references."""
    m = build_map(geojson_obj, center)

    # m.save() writes CDN-linked HTML; capture it as a string to rewrite.
    raw_html: str = m.get_root().render()
    embedded_html = inline_cdn_assets(raw_html)

    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(embedded_html, encoding="utf-8")
    return out_path.resolve()


if __name__ == "__main__":
    sample = {
        "type": "FeatureCollection",
        "features": [
            {
                "type": "Feature",
                "properties": {"name": "Zone A"},
                "geometry": {
                    "type": "Polygon",
                    "coordinates": [[
                        [-74.02, 40.70], [-73.97, 40.70],
                        [-73.97, 40.74], [-74.02, 40.74],
                        [-74.02, 40.70],
                    ]],
                },
            }
        ],
    }
    path = export_selfcontained(sample, [40.72, -74.0], Path("dist/map.html"))
    print(f"Wrote self-contained map: {path}")

Alternative Variants

One-liner save plus a bundled local basemap

When you accept a small local tile set rather than a base64 payload, point the TileLayer at a relative path that ships alongside the HTML. This keeps the file readable while still avoiding any remote server. The directory of tiles travels with the .html as a sibling folder.

import folium

m = folium.Map(location=[40.72, -74.0], zoom_start=11, tiles=None)

# Relative template served from the same directory as the HTML output.
folium.TileLayer(
    tiles="./tiles/{z}/{x}/{y}.png",
    attr="Bundled offline tiles",
    name="offline-basemap",
).add_to(m)

m.save("dist/map.html")  # ships next to dist/tiles/

Embedding decision reference

Dependency Default in m.save() Self-contained approach
Leaflet CSS <link> to CDN Fetch at build, inline as <style>
Leaflet JS <script src> to CDN Fetch at build, inline as <script>
Vector data URL passed to GeoJson Pass parsed dict, serialized as literal
Raster imagery Remote tile server Bundle local tiles or base64 a small set
Marker icons Font Awesome CDN Inline the icon font or use SVG markers

For a fully embedded basemap on a tiny extent, read each PNG tile, base64-encode it, and hand Leaflet a data: URL template — practical only for a handful of tiles because base64 inflates payload by roughly a third. Any residual https:// reference in the output negates the self-contained guarantee and must be resolved before the file is treated as offline-ready. Once the artifact is sealed, the tightened dependency surface also simplifies setting Content-Security-Policy headers for embedded Folium maps, because there are no external origins left to allowlist.

Verification Steps

Prove the artifact is genuinely self-contained before shipping:

  • No remote references remain — grep the output for https:// and http://; a self-contained file should return no matches inside <script src>, <link href>, or tile URL templates.
  • Renders with the network unplugged — disable networking (or use DevTools offline mode) and open the file; controls, styling, and vector overlays must all appear.
  • GeoJSON is a literal, not a fetch — search the HTML for your feature property names; they should appear inline, and the Network panel should show zero data requests.
  • File size is sane — inlining Leaflet plus data typically lands under 1 MB; a multi-megabyte file usually means base64 tiles crept in and should be reviewed.
  • Opens as a top-level page — double-click the file directly; it must render from the file:// scheme without a server, confirming no absolute-path assumptions.

Common Errors & Fixes

The map is blank when opened offline

At least one dependency is still remote. Most often the basemap TileLayer still points at a remote server, or a control library slipped through the inliner. Grep for https://, then either inline the missed asset or set tiles=None and supply a bundled basemap. Reopen with the network disabled to confirm the fix.

Controls appear unstyled or icons are missing boxes

The Leaflet or Font Awesome CSS was not inlined, so control glyphs fall back to empty boxes. Extend the inliner to match every <link rel="stylesheet">, and for marker icons either inline the icon font as a data: URL inside the CSS or switch to folium.DivIcon with inline SVG, which carries no font dependency at all.

File is enormous and slow to open

Base64-embedded tiles are inflating the payload; each encoded tile is about a third larger than its binary form, and a full zoom pyramid explodes quickly. Restrict embedded tiles to a single small extent and zoom range, or move the basemap to a bundled sibling tiles/ directory referenced by a relative path so the imagery stays binary on disk.

URLError or timeout during the build

The inliner fetches CDN assets at build time, so a blocked or offline build environment fails there rather than at open time. Vendor the Leaflet JS/CSS into the repository and read them from disk in fetch() instead of over the network, which also makes the build reproducible in air-gapped CI.