Safely Embedding Folium Maps in React Dashboards

Part of the Iframe Embedding & Isolation guide.

Operative rule: Never inject Folium HTML directly into the React DOM — always isolate it inside a sandboxed <iframe> using a Blob URL, so Leaflet’s scripts run in a separate document context and cannot pollute your application’s global namespace or event system.

How It Works

Folium outputs a complete, self-contained HTML document: inline <script> blocks, Leaflet initialisation routines, and external tile server references are all bundled together. React’s reconciliation engine expects predictable JSX trees. Mixing these two models with dangerouslySetInnerHTML bypasses React’s built-in XSS mitigations and breaks Leaflet’s DOM assumptions simultaneously.

The correct architecture, covered fully in the Iframe Embedding & Isolation cluster, converts the Folium HTML string into a Blob, wraps it in a temporary object URL, and points an <iframe src={blobUrl}> at that URL. The iframe executes in its own document context — a distinct origin for Blob URLs — so Leaflet cannot reach the parent window’s globals, stylesheets, or event listeners. The parent React app communicates back through postMessage, leaving the rendering boundary intact.

This pattern integrates naturally into broader Python-to-Web Generation Workflows where Python builds the HTML artifact and the React frontend receives it as a string prop — no bundler re-compilation, no additional Webpack loaders.

The diagram below shows the three-layer boundary: Python server, Blob URL origin, and React app origin.

Folium-to-React embedding architecture Three boxes connected by arrows: Python server generates Folium HTML, which is converted to a Blob URL in the React component, then rendered inside a sandboxed iframe with a separate document context. Python server folium.Map() .get_root().render() → HTML string prop React component new Blob([html]) URL.createObjectURL() → blob: URL URL.revokeObjectURL() on unmount src= Sandboxed iframe allow-scripts allow-same-origin Leaflet runs here separate document context postMessage (optional cross-frame events)

Why Direct DOM Injection Fails

Attempting to render Folium output with dangerouslySetInnerHTML produces four categories of failure:

  • Tile rendering failures: Leaflet expects a clean, unmodified container. React’s virtual DOM diffing can detach or recreate nodes mid-initialisation, causing blank tiles or flickering on every re-render.
  • Global variable collisions: Leaflet, D3, or custom map plugins write to window. Multiple maps or third-party React libraries can overwrite shared namespaces, triggering uncaught reference errors that are difficult to reproduce.
  • CSS namespace leaks: Folium’s inline styles and Leaflet’s default stylesheet cascade into the React app, breaking layout grids, typography, and component theming.
  • Event handler conflicts: Inline onclick or onload attributes in Folium HTML execute outside React’s synthetic event system, creating memory leaks and untracked listeners that survive component unmount.

The isolation pattern described here eliminates all four categories by giving Leaflet its own document context that React never touches.

Production-Ready Implementation

The component below is the standard production variant. It handles Blob creation, cleanup on unmount, and graceful loading state — with correct sandbox directives for Folium’s tile requests.

import React, { useEffect, useRef, useState } from "react";

/**
 * FoliumMapEmbed — renders a Folium-generated HTML string inside
 * a sandboxed iframe using a Blob URL. Revokes the URL on unmount
 * or when htmlContent changes to prevent memory leaks.
 */
const FoliumMapEmbed = ({ htmlContent, width = "100%", height = "420px" }) => {
  const iframeRef = useRef(null);
  const [blobUrl, setBlobUrl] = useState(null);

  useEffect(() => {
    if (!htmlContent) return;

    // 1. Wrap the Folium HTML string in a Blob.
    //    type: "text/html" is required — without it, browsers refuse to render.
    const blob = new Blob([htmlContent], { type: "text/html" });
    const url = URL.createObjectURL(blob);
    setBlobUrl(url);

    // 2. Revoke the object URL when htmlContent changes or the component unmounts.
    //    Orphaned Blob URLs accumulate in memory and degrade long-lived dashboards.
    return () => {
      URL.revokeObjectURL(url);
      setBlobUrl(null);
    };
  }, [htmlContent]);

  if (!blobUrl) {
    return (
      <div
        role="status"
        aria-label="Map loading"
        style={{ width, height, display: "flex", alignItems: "center", justifyContent: "center" }}
      >
        Loading map…
      </div>
    );
  }

  return (
    <iframe
      ref={iframeRef}
      src={blobUrl}
      title="Embedded Folium Map"
      width={width}
      height={height}
      style={{ border: "1px solid #e2e8f0", borderRadius: "6px", display: "block" }}
      // allow-scripts      — required for Leaflet JS initialisation
      // allow-same-origin  — required for tile XHR requests from blob: origin
      // allow-forms        — required for coordinate-picker popups
      // Do NOT add allow-top-navigation or allow-modals unless you have an
      // explicit requirement — they defeat the security boundary.
      sandbox="allow-scripts allow-same-origin allow-forms"
      referrerPolicy="no-referrer"
      loading="lazy"
    />
  );
};

export default FoliumMapEmbed;

Call it by passing the raw HTML string from your API response or state:

// Example: HTML string fetched from a Python/FastAPI endpoint
const [mapHtml, setMapHtml] = useState(null);

useEffect(() => {
  fetch("/api/generate-map")
    .then((r) => r.text())
    .then(setMapHtml);
}, []);

return mapHtml ? <FoliumMapEmbed htmlContent={mapHtml} height="500px" /> : null;

Alternative Variants

srcdoc for Lightweight Prototypes

srcdoc inlines the HTML as an attribute value rather than a separate URL:

<iframe
  srcdoc={htmlContent}
  title="Prototype Folium Map"
  sandbox="allow-scripts allow-same-origin"
  width="100%"
  height="400px"
/>

Use this only for prototypes. Production constraints that disqualify srcdoc:

Constraint Detail
Size limit Browsers cap srcdoc payloads near 2 MB; Folium maps with embedded GeoJSON often exceed this
CSP context srcdoc executes in the parent document’s security context, conflicting with strict Content-Security-Policy headers
Debugging Network waterfall in DevTools hides tile requests — tile failures are invisible until the map is blank

Sandbox Directive Reference

Directive Required for Folium Risk if Added Without Need
allow-scripts Yes — Leaflet JS will not run without it Low; scripts are already sandboxed
allow-same-origin Yes — tile XHR requests fail from blob: origin without it Medium; grants same-origin storage access
allow-forms Only if map has coordinate-picker popups Low
allow-popups Only if external tile documentation links are needed Medium; opens new browsing contexts
allow-top-navigation Never for maps High; allows iframe to redirect the top frame
allow-modals Never for maps Medium; allows alert() / confirm() dialogs

Verification Steps

After wiring up FoliumMapEmbed, confirm correctness with these checks:

  • Tiles render: Open the map and confirm background tiles load. If blank, check browser DevTools Network tab for blocked XHR requests — most commonly a missing allow-same-origin directive.
  • No console errors in the parent frame: Open DevTools Console and confirm there are no Refused to execute script or Blocked a frame messages. These indicate sandbox is too restrictive.
  • No parent CSS bleed: Inspect the parent app with DevTools and confirm Leaflet’s .leaflet-container rules do not appear in the parent document’s computed styles.
  • Blob URL revoked on unmount: In a React dev-tools profiler session, unmount the component and confirm the Blob URL no longer appears in chrome://blob-internals/ (Chrome only). If it does, the cleanup return in useEffect is not running.
  • Resize correctness: Resize the browser window and confirm tiles repaint without gaps. If tiles misalign, add a ResizeObserver on the iframe container and call iframeRef.current.contentWindow.dispatchEvent(new Event("resize")) when dimensions change.

Common Errors and Fixes

Refused to execute inline script because it violates the following Content Security Policy directive

Root cause: Your host application has a strict Content-Security-Policy header with no blob: allowance in script-src. The Blob URL origin does not match 'self'.

Fix: Blob URL iframes execute in their own origin context — your server’s CSP header should not need modification. If you see this error it usually means the Folium HTML was injected via dangerouslySetInnerHTML rather than via an <iframe src={blobUrl}>. Confirm the iframe pattern is in use.

Blank iframe — no tiles, no map controls

Root cause: Missing allow-same-origin in the sandbox attribute. Tile requests from a blob: origin fail CORS preflight because the origin is opaque.

Fix: Add allow-same-origin to the sandbox value:

sandbox="allow-scripts allow-same-origin allow-forms"

URL.createObjectURL is not a function in SSR (Next.js / Remix)

Root cause: URL.createObjectURL is a browser-only API. Server-side rendering runs in Node.js where it does not exist.

Fix: Guard the effect with an environment check or move the Blob creation into a useEffect (which already only runs in the browser). The component above already does this correctly. If you see this error, verify no code paths call createObjectURL outside a useEffect.

Memory grows steadily in dashboards with frequent map updates

Root cause: URL.revokeObjectURL is not being called, or is called with a stale URL reference. Each re-render creates a new Blob and a new object URL that never releases.

Fix: The useEffect cleanup function in the production implementation above handles this. Verify the dependency array contains [htmlContent] — not [] (which skips cleanup on prop changes) and not no array (which re-runs on every render and may revoke too early).