No Backend Needed: Running Python in React with Pyodide

Introduction

Running Python in the browser used to sound like a gimmick — interesting, but not especially practical. Today, it’s becoming a genuinely useful tool for building data-driven applications, interactive demos, and even lightweight analysis tools without needing a backend.

That’s exactly what Pyodide enables: a full Python runtime compiled to WebAssembly (WASM), running entirely in the browser. With Pyodide, you can execute Python code from JavaScript/TypeScript, load Python packages such as numpy, and even generate charts using matplotlib — all client-side.

In this post, we’ll walk through how to use Pyodide in a modern frontend stack: React + TypeScript + Vite. We’ll cover:

  • how to install and initialize Pyodide inside a React app
  • how to load Python packages dynamically
  • how to run numpy + matplotlib to visualize revenue data
  • how to bridge Python outputs back into React UI

By the end, you’ll have a working React component that executes Python code in the browser and renders a plot generated by Python — no server required.

Setting up Pyodide in Vite

Pyodide can be loaded either directly from a CDN or bundled into your application. For quick prototypes, the CDN option is often the easiest. But for real production apps — especially if you want your app to work offline or avoid depending on an external CDN — bundling Pyodide into your Vite build is a great choice.

In this section, we’ll configure Vite + React + TypeScript so Pyodide works in:

  • Vite dev mode (npx vite)
  • production build (npx vite build)
  • production preview (npx vite preview)

Option 1 — Loading Pyodide from a CDN (quickest setup)

For many applications the simplest approach is to load Pyodide using a CDN by providing the indexURL parameter:

import { loadPyodide, version as pyodideVersion } from "pyodide";

async function initPyodide() {
  const pyodide = await loadPyodide({
    indexURL: `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`,
  });

  return pyodide;
}

This approach works with most bundlers without additional configuration and is recommended for many users. It’s also great if you want to avoid adding Pyodide assets to your own build output.

Option 2 — Bundling Pyodide in Vite (recommended for production apps)

When using Vite, Pyodide requires a small amount of additional configuration:

  1. Pyodide must be excluded from dependency pre-bundling
  2. Pyodide runtime files must be copied into the final build output (dist/assets)

To do that, install the required packages:

npm install pyodide vite-plugin-static-copy

Configure Vite to copy Pyodide assets

In your project, update vite.config.ts to:

  • exclude pyodide from optimizeDeps, and
  • copy Pyodide distribution files to the build output via vite-plugin-static-copy

Here is the exact TypeScript config (React included) that works well:

import { defineConfig } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import react from "@vitejs/plugin-react";

const PYODIDE_EXCLUDE = [
  "!**/*.{md,html}",
  "!**/*.d.ts",
  "!**/*.whl",
  "!**/node_modules",
];

export function viteStaticCopyPyodide() {
  const pyodideDir = dirname(fileURLToPath(import.meta.resolve("pyodide")));
  return viteStaticCopy({
    targets: [
      {
        src: [join(pyodideDir, "*")].concat(PYODIDE_EXCLUDE),
        dest: "assets",
      },
    ],
  });
}

// https://vite.dev/config/
export default defineConfig({
  optimizeDeps: { exclude: ["pyodide"] },
  plugins: [react(), viteStaticCopyPyodide()],
});

With this setup, Vite will ensure Pyodide files such as pyodide.js, .wasm, and supporting runtime files are copied into dist/assets/ for production builds

Setting indexURL when using bundled assets*

Once Pyodide is copied into dist/assets, you may want to explicitly point Pyodide to the correct path using indexURL:

const pyodide = await loadPyodide({
  indexURL: "/assets",
});

This tells Pyodide where to find pyodide.js, .wasm, and related files.

Loading packages

Once Pyodide is initialized, the next step is getting access to the Python ecosystem you care about. Pyodide ships with a large set of prebuilt packages (including numpy and matplotlib) that you can load on demand using pyodide.loadPackage().

The core API: pyodide.loadPackage()

Packages included in the official Pyodide repository can be loaded like this:

await pyodide.loadPackage("numpy");

A few important behaviors to know:

  • Dependencies are handled automatically when you load from the official Pyodide repository. If a package depends on other packages, Pyodide will load them too.
  • You can load multiple packages at once by passing a list:
await pyodide.loadPackage(["numpy", "matplotlib"]);
  • loadPackage() returns a Promise that resolves once everything is loaded, so you typically do:

const pyodide = await loadPyodide(); await pyodide.loadPackage("matplotlib"); // matplotlib is now available

  • In general, loading a package twice is not permitted. (So it’s best to keep Pyodide as a singleton and track what you’ve loaded.)

Loading packages from custom URLs (advanced)

You can also load a wheel directly from a URL:

await pyodide.loadPackage(
  "https://foo/bar/numpy-1.22.3-cp310-cp310-emscripten_3_1_13_wasm32.whl",
);

Two gotchas:

  • The filename must be a valid wheel name.
  • No dependency resolution happens for custom URLs. If you want dependency resolution for wheels from arbitrary URLs (or from PyPI), you’ll typically use micropip instead. It is a lightweight package installer for Pyodide that lets you install pure-Python packages (typically from PyPI) in the browser, and it can also handle dependency resolution when installing packages from custom wheels or URLs.

Where packages are stored (and why subsequent loads are faster)

When you call pyodide.loadPackage(...), Pyodide fetches the required package artifacts (and dependencies) from the configured indexURL (CDN or your local /assets folder if you bundled it with Vite).

From there, two kinds of caching typically help:

  • Browser HTTP cache The downloaded assets (e.g., .js, .data, .wasm, package files) are cached by the browser according to normal HTTP caching rules. This usually means the second page load is significantly faster than the first.

  • In-memory runtime state (per page load) Within a single session (while the tab is open), once packages are loaded into the Pyodide runtime, they’re immediately available for subsequent Python calls—no additional downloads needed.

If you want “offline-ish” behavior and long-lived caching guarantees, you can pair this with a Service Worker (e.g., via a PWA setup) to precache /assets or CDN resources. But even without that, normal browser caching already provides a nice speed-up after the first run.

Using numpy and matplotlib to visualize revenue data

Full source code for this demo is available here: https://github.com/ilich/demo-pyodide If you want to follow along with a working version, the repo includes the full Vite + React + TypeScript setup plus the Python script and hooks.

Now for the fun part: running real Python data processing in the browser and rendering a Matplotlib chart in a React component.

The goal of this section is:

  1. accept raw CSV text from the UI
  2. process it in Python using csv + numpy
  3. generate a chart using matplotlib
  4. return the chart as an SVG string
  5. render the SVG inside React

Python: parse CSV, aggregate revenue, generate an SVG plot

We’ll keep the Python logic simple and self-contained. It reads CSV text, sums revenue per industry using numpy, and uses Matplotlib to generate a bar chart. Instead of writing a file, it returns the rendered chart as an SVG string:

import csv
import io
import numpy as np
import matplotlib.pyplot as plt


def plot_revenue_by_industry(csv_text: str) -> str:
    # Read data from CSV using csv reader
    data = []
    reader = csv.reader(io.StringIO(csv_text))
    _ = next(reader)  # Skip header

    for row in reader:
        company_name, industry, revenue = row
        data.append((company_name, industry, float(revenue)))

    # Load data to numpy
    np_data = np.array(data, dtype=object)

    industries = np_data[:, 1]
    revenues = np_data[:, 2].astype(float)

    # Sum revenue by industry
    unique_industries = np.unique(industries)

    industry_revenue_sum = []
    for ind in unique_industries:
        total = revenues[industries == ind].sum()
        industry_revenue_sum.append((ind, total))

    industry_revenue_sum = np.array(industry_revenue_sum, dtype=object)

    # Plot with bar chart using matplotlib
    plt.figure(figsize=(8, 5))
    plt.bar(industry_revenue_sum[:, 0], industry_revenue_sum[:, 1].astype(float))
    plt.xlabel("Industry")
    plt.ylabel("Total Revenue")
    plt.title("Total Revenue by Industry")
    plt.xticks(rotation=30)
    plt.tight_layout()

    # Save to SVG and return
    svg_io = io.StringIO()
    plt.savefig(svg_io, format='svg', bbox_inches='tight')
    svg_string = svg_io.getvalue()
    plt.close()

    return svg_string

Returning SVG makes the browser integration painless: the output is just a string that React can render directly.

React Hook: initialize Pyodide once and reuse it everywhere

Here’s the key: a usePyodide() hook that wraps initialization, package loading, and shared caching.

What it does

  • Uses a module-level singleton (pyodideInstance) so the runtime is created only once.
  • Uses a shared promise (pyodideLoadingPromise) so if multiple components mount at the same time, they don’t trigger multiple downloads.
  • Loads numpy and matplotlib exactly once.
import { useEffect, useState } from "react";
import { loadPyodide } from "pyodide";
import type { PyodideInterface } from "pyodide";

let pyodideInstance: PyodideInterface | null = null;
let pyodideLoadingPromise: Promise<PyodideInterface> | null = null;

async function getPyodide(): Promise<PyodideInterface> {
  if (pyodideInstance) {
    return pyodideInstance;
  }

  if (pyodideLoadingPromise) {
    return pyodideLoadingPromise;
  }

  pyodideLoadingPromise = loadPyodide({
    indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
  }).then(async (pyodide) => {
    await pyodide.loadPackage(["numpy", "matplotlib"]);
    pyodideInstance = pyodide;
    return pyodide;
  });

  return pyodideLoadingPromise;
}

interface UsePyodideResult {
  pyodide: PyodideInterface | null;
  loading: boolean;
  error: string | null;
}

export function usePyodide(): UsePyodideResult {
  const [pyodide, setPyodide] = useState<PyodideInterface | null>(
    pyodideInstance,
  );
  const [loading, setLoading] = useState(!pyodideInstance);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (pyodideInstance) {
      return;
    }

    let cancelled = false;

    getPyodide()
      .then((instance) => {
        if (!cancelled) {
          setPyodide(instance);
          setLoading(false);
        }
      })
      .catch((err) => {
        if (!cancelled) {
          setError(err.message || "Failed to load Pyodide");
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, []);

  return { pyodide, loading, error };
}

When pyodide.loadPackage(["numpy", "matplotlib"]) runs:

  • Pyodide downloads the package artifacts from the indexURL (in this case, the jsDelivr CDN).
  • Those files are cached client-side using normal browser HTTP caching rules, so repeat visits are typically much faster.
  • Within a single page session, once the packages are loaded into the Pyodide runtime, they’re available immediately for all later computations (no re-download).

This is why the singleton hook pattern matters: you get both runtime reuse (in-memory) and download reuse (browser cache).

React Component: run Python and render the returned SVG

The React component calls the Python function and renders the SVG returned by Matplotlib:

  • It waits for usePyodide() to finish loading
  • It runs your Python script (loaded as a raw string via Vite ?raw)
  • It calls plot_revenue_by_industry(csvData)
  • It injects the returned SVG into the DOM
import { useEffect, useState } from "react";
import { Loader } from "./Loader";
import { Alert } from "react-bootstrap";
import { usePyodide } from "../hooks/usePyodide";
import revenueScrpt from "../assets/revenue.py?raw";

interface RevenueChartProps {
  csvData: string | null;
}

export const RevenueChart = ({ csvData }: RevenueChartProps) => {
  const {
    pyodide,
    loading: pyodideLoading,
    error: pyodideError,
  } = usePyodide();

  const [calculating, setCalculating] = useState(false);
  const [calcError, setCalcError] = useState<string | null>(null);
  const [chartData, setChartData] = useState<string | null>(null);

  useEffect(() => {
    const calculateRevenue = async () => {
      if (!csvData || !pyodide) return;

      setCalculating(true);
      try {
        await pyodide.runPythonAsync(revenueScrpt);

        const plotRevenueByIndustry = pyodide.globals.get(
          "plot_revenue_by_industry",
        );

        const svg: string = plotRevenueByIndustry(csvData);
        setChartData(svg);
        setCalcError(null);
      } catch (error) {
        console.error("Error running Python code:", error);
        setChartData(null);
        setCalcError("Failed to run Python code");
      } finally {
        setCalculating(false);
      }
    };

    calculateRevenue();
  }, [csvData, pyodide]);

  if (pyodideLoading || calculating) {
    return <Loader visible />;
  }

  if (!csvData) {
    return <div>Please provide CSV data to analyze.</div>;
  }

  const error = pyodideError || calcError;
  if (error) {
    return <Alert variant="danger">Error: {error}</Alert>;
  }

  return (
    <div>
      <div dangerouslySetInnerHTML={{ __html: chartData || "" }} />
    </div>
  );
};

A note about dangerouslySetInnerHTML

Because Matplotlib returns raw SVG markup, the simplest rendering approach is injecting it as HTML. This is fine here because:

  • the SVG is generated by your own Python code
  • the input is structured CSV (still: treat user-provided CSV as untrusted in real apps)

If you ever render SVG/HTML generated from untrusted input, you should sanitize it first!

Conclusion

Pyodide makes it surprisingly practical to run Python directly inside a modern frontend app. With React + TypeScript + Vite, we can load a full Python runtime in the browser, install scientific packages like numpy and matplotlib, and use them to perform real computations and generate visualizations — all without a backend.

This pattern is not just a demo — it unlocks a lot of useful browser-native workflows:

  • Interactive data visualization dashboards Let users upload CSV files and instantly explore insights, plots, and analytics — without sending data to a server.

  • Privacy-first / offline-first analytics tools Great for sensitive datasets (finance, healthcare, internal company reports) where you want computations to run entirely client-side.

  • Education and tutorials Build Python-powered playgrounds directly into web pages: data science lessons, statistics examples, “try it” notebooks, etc.

  • Scientific or engineering calculators Some organizations already have reliable Python implementations — Pyodide lets you reuse those models directly in the browser.

  • Replacing backend microservices for lightweight compute For certain tasks (format conversion, data cleaning, small statistical models), running Python in the client can remove infrastructure complexity.

If you want to explore further, the next natural improvements could be:

  • building a more generic “Python runner” abstraction (not tied to revenue charts)
  • adding progress events while packages load
  • caching computed results
  • using micropip to install additional pure-Python packages dynamically

And if you want to see the full working code, you can find it here: https://github.com/ilich/demo-pyodide

References