Building standalone interactive calculators and dashboards that embed in policyengine.org
From essentialnpx claudepluginhub policyengine/policyengine-claude --plugin data-scienceThis skill uses the workspace's default tool permissions.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Migrates code, prompts, and API calls from Claude Sonnet 4.0/4.5 or Opus 4.1 to Opus 4.5, updating model strings on Anthropic, AWS, GCP, Azure platforms.
Optimizes cloud costs on AWS, Azure, GCP via rightsizing, tagging strategies, reserved instances, spot usage, and spending analysis. Use for expense reduction and governance.
How to build standalone React apps (calculators, dashboards, visualizations) that embed in policyengine.org via iframe.
PolicyEngine/marriage) — uses PolicyEngine APIPolicyEngine/givecalc) — custom Modal API with policyengine-usPolicyEngine/aca-calc) — precomputed dataPolicyEngine/state-legislative-tracker) — static dataPolicyEngine/uk-salary-sacrifice-analysis)PolicyEngine/snap-bbce-repeal) — precomputed CSV dashboardNext.js 14 + Tailwind 4 + Recharts for all tools (embeddable and standalone).
| Component | Choice |
|---|---|
| Framework | Next.js 14 (App Router) |
| CSS | Tailwind 4 with @policyengine/ui-kit theme |
| Charts | Recharts |
| Code highlighting | Prism React Renderer |
| Testing | Vitest |
| Deploy | Vercel under policy-engine scope |
| Package manager | bun (not npm) |
Requirements:
@policyengine/ui-kit theme (installed via bun add @policyengine/ui-kit)var(--primary), var(--chart-1), var(--font-sans))policyengine-app-v2/app/public/assets/logos/policyengine/ (white.png for dark backgrounds, teal.png for light)NEVER manually copy numbers from ad-hoc calculations (bash, Python REPL, etc.) into source files. All data displayed in charts or UI must come from a generation script that writes to a data file (JSON, CSV) which the frontend imports.
The correct flow is always:
Python script (reads reform/config) → data file (JSON/CSV) → frontend imports data file
Never:
Ad-hoc Python in terminal → copy numbers → paste into .tsx/.jsx file
If a repo has a data generation script (e.g., scripts/generate_*.py), update that script and re-run it. If one doesn't exist, create one. The script should:
reform.json)Simulation call)Choose based on what the tool needs from PolicyEngine:
Best when the parameter space is small enough to enumerate, or the tool shows static analysis results.
When to use: Dashboards showing pre-run scenarios, legislative trackers, tools where inputs map to a finite set of outputs.
┌─────────────┐ ┌──────────┐ ┌───────────┐
│ Python script│───>│ JSON file│───>│ Next.js │
│ (one-time) │ │ (static) │ │ (fast) │
└─────────────┘ └──────────┘ └───────────┘
Example: State legislative tracker pre-computes budget impacts for every state bill and ships a JSON file.
# scripts/precompute.py
from policyengine_us import Microsimulation
results = {}
for reform_id, reform in reforms.items():
sim = Microsimulation(reform=reform)
results[reform_id] = {
"revenue_change": float(sim.calculate("revenue_change")),
"poverty_change": float(sim.calculate("poverty_change")),
}
with open("src/data/results.json", "w") as f:
json.dump(results, f)
// React — just reads the JSON
import results from "./data/results.json";
function Dashboard({ reformId }) {
const data = results[reformId];
return <MetricCard value={data.revenue_change} />;
}
Pros: Zero latency, no API costs, works offline. Cons: Can't handle continuous user inputs; stale if policy changes.
Best when the tool calculates household-level impacts with varying incomes/demographics. The main PolicyEngine API (api.policyengine.org) handles standard household simulations.
When to use: Tools where users enter income, family size, state, and see tax/benefit impacts. Works when all the variables you need are in the PolicyEngine API.
┌───────────┐ ┌──────────────────┐ ┌──────────┐
│ Next.js │───>│ api.policyengine │───>│ Results │
│ (browser) │<───│ .org/us/calculate │<───│ │
└───────────┘ └──────────────────┘ └──────────┘
Example: Marriage calculator sends household JSON and gets back tax/benefit amounts.
// api.js
const API_BASE = "https://api.policyengine.org";
export async function calculateHousehold(countryId, household) {
const res = await fetch(`${API_BASE}/${countryId}/calculate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ household }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
Household JSON structure:
{
"people": {
"head": { "age": { "2025": 40 }, "employment_income": { "2025": 50000 } },
"spouse": { "age": { "2025": 35 }, "employment_income": { "2025": 30000 } }
},
"tax_units": { "tax_unit": { "members": ["head", "spouse"] } },
"spm_units": { "spm_unit": { "members": ["head", "spouse"] } },
"households": { "household": { "members": ["head", "spouse"], "state_code": { "2025": "CA" } } }
}
Comparing scenarios: To show the effect of marriage, call the API twice (unmarried vs married household) and diff the results.
Pros: Always up-to-date with latest policy rules, handles arbitrary inputs. Cons: Network latency (1-5s per call), rate limits, limited to variables the API supports.
Best when you need variables or calculations not in the main PolicyEngine API — custom reform parameters, non-standard entity structures, or computations that combine PolicyEngine with other models.
Decision rule: Before choosing Pattern C, verify that the PolicyEngine API (
api.policyengine.org) cannot handle the computation. Pattern C is only needed when:
- You need microsimulation (society-wide) results
- You need custom reform parameters not exposed by the API
- You need variables or entity structures not supported by the API
If the tool only needs household-level calculations, Pattern B (PolicyEngine API) is always preferred — it's faster, always up-to-date, and requires no backend maintenance.
When to use: Tools that vary parameters not exposed by the main API (e.g., varying UBI amounts, custom phase-outs), or tools that need microsimulation (society-wide) results for arbitrary reforms.
Architecture: Two-layer gateway + worker with frontend polling. This mirrors the pattern used by PolicyEngine API v1 and API v2.
┌───────────┐ POST /submit ┌──────────────────┐ spawn() ┌──────────────┐
│ Next.js │──────────────>│ Gateway (FastAPI) │─────────>│ Worker │
│ (browser) │ │ (lightweight) │ │ (policyengine)│
│ │ GET /status │ │ poll │ │
│ │<──────────────│ │<─────────│ │
└───────────┘ {status,data} └──────────────────┘ └──────────────┘
Resource principle: The gateway and workers have opposite resource profiles:
| Layer | CPU | Memory | Scaling | Why |
|---|---|---|---|---|
| Gateway | Minimal (default) | Minimal (128–256 MB) | Always-on is fine — it's cheap | Only does HTTP routing, spawn(), and FunctionCall.from_id() — no heavy computation |
| Workers | High (4–8 CPU) | High (16–32 GB) | Must wind down to zero instances | Expensive to keep warm; Modal cold-starts are fast (~2s with image snapshot) |
The gateway MUST be lightweight — no policyengine-us/policyengine-uk dependency, no large memory allocation. It exists solely to accept requests, dispatch jobs to workers via spawn(), and report status. Keep its image small (just fastapi and pydantic) and its resource footprint minimal.
The worker functions do the heavy lifting (loading the tax-benefit system, running simulations) and should be configured with high CPU/memory. But they MUST be allowed to scale to zero when idle — never set keep_warm or min_containers on worker functions. Modal's image snapshot (via .run_function()) keeps cold starts fast enough that always-warm workers are not worth the cost.
Why not synchronous HTTP? Modal's dev gateway (modal serve) and production gateway have a ~150s timeout. Long-running requests (like US statewide microsimulations, which take 2-5+ minutes) get an HTTP 303 redirect that browser fetch() cannot follow for POST requests. The gateway + polling architecture avoids this entirely.
The backend uses a three-file structure mirroring policyengine-api-v2's simulation service. This prevents a common crash-loop where module-level imports of pydantic or policyengine fail because those packages are only available inside the Modal function's image, not at module import time.
| File | Purpose | Module-level imports |
|---|---|---|
backend/_image_setup.py | Standalone snapshot function — runs during image build | None (all inside function body) |
backend/app.py | Modal app + function decorators | Only modal |
backend/simulation.py | Pure business logic | policyengine_us/_uk (captured in image snapshot) |
backend/modal_app.py | Lightweight gateway (FastAPI) | modal, fastapi, pydantic |
backend/_image_setup.py)Standalone function with no package imports at module level — executed during image build via .run_function():
def snapshot_models():
"""Pre-load models at image build time for fast cold starts."""
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Pre-loading tax-benefit system...")
from policyengine_us import CountryTaxBenefitSystem # or policyengine_uk
CountryTaxBenefitSystem()
logger.info("Models pre-loaded into image snapshot")
backend/app.py)Only modal at module level. Imports business logic inside each function body:
import modal
from pathlib import Path
from _image_setup import snapshot_models
app = modal.App("my-tool-workers")
_BACKEND_DIR = Path(__file__).parent
image = (
modal.Image.debian_slim(python_version="3.11")
.pip_install("policyengine-us==X.Y.Z", "pydantic") # Pin to latest — look up from PyPI
.run_function(snapshot_models)
.add_local_file(str(_BACKEND_DIR / "simulation.py"), remote_path="/root/simulation.py")
)
# Workers: high resources, but wind down to zero when idle.
# NEVER set keep_warm or min_containers — cold starts are fast thanks to image snapshot.
@app.function(image=image, cpu=8.0, memory=32768, timeout=3600)
def compute_household(params: dict) -> dict:
from simulation import run_household
return run_household(params)
@app.function(image=image, cpu=8.0, memory=32768, timeout=3600)
def compute_statewide(params: dict) -> dict:
from simulation import run_statewide
return run_statewide(params)
backend/simulation.py)Pure business logic — policyengine imports at module level (captured in the image snapshot via .run_function()). No Modal imports here.
from policyengine_us import Simulation, Microsimulation # Snapshotted at build time
def run_household(params: dict) -> dict:
sim = Simulation(situation=params["household"])
return {
"net_income": float(sim.calculate("household_net_income", 2025).sum()),
}
def run_statewide(params: dict) -> dict:
baseline = Microsimulation()
reform = Microsimulation(reform=params["reform"])
# ... compute impacts
return {"revenue_change": ..., "winners": ..., "losers": ...}
backend/modal_app.py)The gateway is lightweight — no policyengine dependency. It spawns worker jobs and polls for results:
import modal
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = modal.App("my-tool")
gateway_image = modal.Image.debian_slim(python_version="3.11").pip_install(
"fastapi", "pydantic",
)
WORKER_APP = "my-tool-workers"
FUNCTION_MAP = {
"household-impact": "compute_household",
"statewide-impact": "compute_statewide",
}
web_app = FastAPI()
web_app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
class SubmitResponse(BaseModel):
job_id: str
class StatusResponse(BaseModel):
status: str # "computing" | "ok" | "error"
result: dict | None = None
message: str | None = None
@web_app.post("/submit/{endpoint}")
def submit(endpoint: str, params: dict):
if endpoint not in FUNCTION_MAP:
raise HTTPException(status_code=404, detail=f"Unknown endpoint: {endpoint}")
fn = modal.Function.from_name(WORKER_APP, FUNCTION_MAP[endpoint])
call = fn.spawn(params)
return SubmitResponse(job_id=call.object_id)
@web_app.get("/status/{job_id}")
def status(job_id: str):
from modal.functions import FunctionCall
call = FunctionCall.from_id(job_id)
try:
result = call.get(timeout=0)
return StatusResponse(status="ok", result=result)
except TimeoutError:
return StatusResponse(status="computing")
except Exception as e:
return StatusResponse(status="error", message=str(e))
# Gateway: minimal resources — just HTTP routing, no heavy computation.
@app.function(image=gateway_image, memory=256)
@modal.asgi_app()
def fastapi_app():
return web_app
const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://policyengine--my-tool-fastapi-app.modal.run";
export async function submitJob(endpoint: string, params: unknown): Promise<string> {
const res = await fetch(`${API_URL}/submit/${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error(`Submit failed: ${res.status}`);
const data = await res.json();
return data.job_id;
}
export async function pollStatus(jobId: string) {
const res = await fetch(`${API_URL}/status/${jobId}`);
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
return res.json(); // { status: "computing" | "ok" | "error", result?, message? }
}
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { submitJob, pollStatus } from "../api/client";
export function useAsyncCalculation(queryKey: unknown[], endpoint: string, params: unknown, enabled = true) {
const [jobId, setJobId] = useState<string | null>(null);
// Step 1: Submit job when params change
const submit = useQuery({
queryKey: [...queryKey, "submit"],
queryFn: async () => {
const id = await submitJob(endpoint, params);
setJobId(id);
return id;
},
enabled,
});
// Step 2: Poll for results
const poll = useQuery({
queryKey: [...queryKey, "poll", jobId],
queryFn: () => pollStatus(jobId!),
enabled: !!jobId,
refetchInterval: (query) =>
query.state.data?.status === "computing" ? 2000 : false,
});
return {
isLoading: submit.isLoading || (!!jobId && poll.isLoading),
isComputing: poll.data?.status === "computing",
isError: submit.isError || poll.data?.status === "error",
data: poll.data?.status === "ok" ? poll.data.result : undefined,
error: poll.data?.message || submit.error?.message,
};
}
Deploy:
# Deploy the worker functions first (includes image snapshot — first build takes ~5 min)
unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET
modal deploy backend/app.py
# Deploy the gateway
modal deploy backend/modal_app.py
URL pattern: https://policyengine--my-tool-fastapi-app.modal.run
Set Vercel env var:
vercel env add NEXT_PUBLIC_API_URL production
# Enter: https://policyengine--my-tool-fastapi-app.modal.run
vercel --prod --force --yes --scope policy-engine
Pros: Full control over calculations, can use any policyengine variables/reforms, can do microsimulation, no timeout issues. Cons: Fast cold starts (~2s thanks to model pre-loading via .run_function(); without snapshot, cold starts take 3-5 minutes), Modal costs, must pin policyengine version, must redeploy when policy rules update, more complex architecture (four files).
Failure mode: Modal apps can silently disappear. If frontend gets network errors, curl the Modal URL — if 404, redeploy.
| Context | Default timeout | Max timeout | Notes |
|---|---|---|---|
@app.function(timeout=...) | 300s | 86,400s (24h) | Set per-function |
modal serve dev gateway | ~150s | Not configurable | Returns HTTP 303 on timeout |
modal deploy prod gateway | ~150s | Not configurable | Returns HTTP 303 on timeout |
US statewide microsimulations take 2-5+ minutes. This exceeds the gateway timeout, which is why synchronous HTTP calls fail for microsimulation endpoints. The gateway + polling architecture avoids this by using non-blocking job submission. Household-level simulations typically complete in 10-40s, within the gateway timeout, but polling is still recommended for consistency.
For analysis repos that precompute data with Python microsimulation pipelines:
┌─────────────────┐ ┌──────────┐ ┌────────────────┐
│ Python pipeline │───>│ CSV files│───>│ Next.js app │
│ (Microsimulation)│ │ public/ │ │ (static export)│
└─────────────────┘ └──────────┘ └────────────────┘
Python side: Pipeline generates CSVs to public/data/.
Frontend side: Fetch CSVs at runtime, parse with a lightweight CSV parser.
Example: PolicyEngine/snap-bbce-repeal, PolicyEngine/uk-spring-statement-2026.
bunx create-next-app@14 my-tool --js --app --tailwind --eslint --no-src-dir --import-alias "@/*"
cd my-tool
bun add @policyengine/ui-kit recharts
bun add -D vitest
import "./globals.css";
export const metadata = {
title: "TOOL_TITLE | PolicyEngine",
description: "DESCRIPTION",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
}
@import "tailwindcss";
@import "@policyengine/ui-kit/theme.css";
body {
font-family: var(--font-sans);
color: var(--foreground);
background: var(--background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
The single @import "@policyengine/ui-kit/theme.css" replaces the entire manual @theme block. It provides all color, spacing, and typography tokens as CSS variables that Tailwind 4 picks up automatically.
Use Tailwind classes from the ui-kit theme:
<div className="bg-muted border border-border rounded-lg p-4">
Or use style= with var() for inline styles:
<div style={{
backgroundColor: "var(--muted)",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
padding: "1rem",
}}>
Add entry to policyengine-app-v2/app/src/data/apps/apps.json:
{
"type": "iframe",
"slug": "my-tool",
"title": "My interactive tool",
"description": "What this tool does",
"source": "https://my-tool-auto-url.vercel.app/",
"tags": ["us", "featured", "policy", "interactives"],
"countryId": "us",
"displayWithResearch": true,
"image": "my-tool-cover.png",
"date": "2026-02-14 12:00:00",
"authors": ["author-slug"]
}
App types: iframe (standard), obbba-iframe (special layout), custom (React component).
Multi-country: Same slug, different countryId:
{ "slug": "marriage", "countryId": "us", ... },
{ "slug": "marriage", "countryId": "uk", "displayWithResearch": false, ... }
Source URL: Use the auto-assigned Vercel production URL (e.g., marriage-zeta-beryl.vercel.app), not a custom alias — aliases may have deployment protection issues.
Required fields for displayWithResearch: true: image, date, authors.
When embedded at /uk/my-tool, policyengine.org injects #country=uk into the iframe URL.
// Read country from hash — independently of other params
function getCountryFromHash() {
const params = new URLSearchParams(window.location.hash.slice(1));
return params.get("country") || "us";
}
const [countryId, setCountryId] = useState(getCountryFromHash());
Important: Read country independently. Don't require region or income to be present — the parent may only send #country=uk.
The parent app syncs the iframe hash to the browser URL bar:
// Update hash when inputs change
const hash = `#region=CA&head=50000&spouse=40000`;
window.history.replaceState(null, "", hash);
// Notify parent
if (window.self !== window.top) {
window.parent.postMessage({ type: "hashchange", hash }, "*");
}
When embedded, skip the country param in hash — it's redundant with the URL path:
const isEmbedded = window.self !== window.top;
if (countryId !== "us" && !isEmbedded) params.set("country", countryId);
Point to policyengine.org, not the Vercel URL:
function getShareUrl(countryId) {
const hash = window.location.hash;
if (window.self !== window.top) {
return `https://policyengine.org/${countryId}/my-tool${hash}`;
}
return window.location.href;
}
Hide when embedded (country comes from the route):
<InputForm countries={isEmbedded ? null : COUNTRIES} ... />
Recharts is the PE standard for all charts:
bun add recharts
For simple visualizations: Use SVG directly. The marriage calculator uses hand-rolled SVG heatmaps.
Color conventions:
var(--chart-1)var(--chart-3) or var(--destructive)var(--border)Inverted metrics (taxes): When positive delta means bad (more taxes), pass invertDelta to your chart component to flip labels and colors.
Recharts accepts CSS variables directly via fill and stroke props:
<BarChart data={data}>
<CartesianGrid stroke="var(--border)" />
<XAxis niceTicks="snap125" domain={["auto", "auto"]} tick={{ fontSize: 12, fontFamily: "var(--font-sans)" }} />
<YAxis niceTicks="snap125" domain={["auto", "auto"]} tick={{ fontSize: 12, fontFamily: "var(--font-sans)" }} />
<Bar dataKey="value" fill="var(--chart-1)" />
</BarChart>
Always set niceTicks="snap125" on every <XAxis> and <YAxis>. This snaps tick step sizes to {1, 2, 2.5, 5} × 10^n, producing human-friendly round labels like 0, 5, 10, 15, 20. Do NOT use niceTicks as a bare boolean or niceTicks="auto" — always specify "snap125" explicitly. The snap125 algorithm may leave some blank space at chart edges; this is the correct trade-off for readability.
Always pair with domain={["auto", "auto"]} — the default recharts domain [0, 'auto'] clamps the minimum to 0, which breaks tick calculation for data that doesn't start at 0 (e.g., all-negative values). Setting both ends to "auto" lets recharts compute the domain from the data.
Format negative dollar values as -$100 not $-100 — use a custom tickFormatter like:
tickFormatter={(v) => v < 0 ? `-$${Math.abs(v)}` : `$${v}`}
Never pass hardcoded hex values like fill="#319795" to Recharts — always use CSS variables (e.g., fill="var(--chart-1)").
For tools that show code or formulas, use Prism React Renderer:
bun add prism-react-renderer
Use Tailwind responsive prefixes (sm:, md:, lg:) or custom media queries:
/* Tablet — sidebar collapses to top */
@media (max-width: 768px) { ... }
/* Phone — form rows stack */
@media (max-width: 480px) {
.form-row { flex-direction: column; }
}
Key patterns:
bun add -D vitest
bunx vitest run
Test API responses against Python fixtures for numerical accuracy. See PolicyEngine/marriage/tests/ for examples.
curl returning 200 does NOT mean a frontend works. SPAs serve an HTML shell regardless of whether React components render. The only reliable check is bun run build.lsof -i :<port>.bun install fails, try at most 2 approaches before asking the user. Do not rabbit-hole into manual tar extraction, rm -rf node_modules, or obscure npm flags.@policyengine/ui-kit installed (bun add @policyengine/ui-kit)@import "@policyengine/ui-kit/theme.css" in globals.cssvar(--font-sans)fill="var(--chart-1)" pattern for SVG props (font, colors)niceTicks="snap125" with domain={["auto", "auto"]} for human-friendly tick values-$100 not $-100policy-engine scope#country=uk)displayWithResearch)policyengine-design-skill — Full token referencepolicyengine-vercel-deployment-skill — Vercel deployment patternspolicyengine-app-skill — app-v2 development (different from standalone tools)