| name | policyengine-interactive-tools |
| description | Building standalone interactive calculators and dashboards that embed in policyengine.org |
PolicyEngine interactive tools
How to build standalone React apps (calculators, dashboards, visualizations) that integrate into policyengine.org as Next.js multi-zones (preferred) or via iframe (legacy).
Examples
Use /new-tool plus this skill as the canonical scaffold for new repos. The production repos below are useful pattern references, but they do not all match the current frontend standard.
Pattern references
- GiveCalc (
PolicyEngine/givecalc) — custom Modal API with policyengine-us
- State legislative tracker (
PolicyEngine/state-legislative-tracker) — precomputed/static data with external app embedding
- SNAP BBCE repeal dashboard (
PolicyEngine/snap-bbce-repeal) — precomputed CSV dashboard
Legacy repos still in production
- Marriage calculator (
PolicyEngine/marriage) — older Vite-era frontend; use for business logic, not scaffolding
- ACA reforms calculator (
PolicyEngine/ACA-Calc) — older Vite-era frontend; use for data ideas, not scaffolding
- Student loan calculator (
PolicyEngine/student-loan-calculator) — legacy design-system/CDN setup; do not copy
- Spring Statement dashboard (
PolicyEngine/uk-spring-statement-2026) — custom migration case; use for policy logic, not baseline architecture
Current default stack
New tools default to Next.js 14 + Tailwind 4 + Recharts. Some deployed tools predate this stack; treat those repos as migration targets or pattern references, not templates.
| 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)
- Inter font via Google Fonts CDN
- Recharts for charts
- NEVER hardcode hex colors or font names — always use CSS variables from the ui-kit theme (e.g.,
var(--primary), var(--chart-1), var(--font-sans))
- PolicyEngine logo — always use the actual logo image, never styled text. Copy it locally from
@policyengine/ui-kit or PolicyEngine assets; never hotlink a raw GitHub URL
- Every new repo needs pull-request CI before launch — at minimum install + build, and add lint/test when scripts exist
- Sentence case on all UI text
- App-specific provenance belongs in the app's results/methodology footnote —
policyengine.py versions, model/data versions, static-estimate caveats, and calculation notes must not be added to the shared PolicyEngine header, footer, or global layout. When touching these notes, add or preserve a regression test that the shared chrome does not contain app-specific provenance and the results/methodology footnote does.
Multi-zone integration (preferred)
PolicyEngine tools integrate with policyengine.org as Next.js multi-zones. The host website (policyengine-app-v2/website/) proxies specific URL paths to standalone Vercel deployments via rewrites, so users see one site while each tool remains independently deployable.
Multi-zone replaces iframe embedding for all new tools. Iframe embedding is retained only for legacy tools and the obbba-iframe / custom apps.json types — see "Legacy iframe embedding" below.
The decision rule
Pick the pattern from the zone's path shape first, then layer on the build-type config:
| Zone owns | Pattern |
|---|
| One public path matching the repo's kebab name | Path-mounted zone — literal basePath |
Multiple public paths (e.g. /us/api + /uk/api) | Root-served zone — no basePath, host rewrites map each public path to the zone root |
| Zone build type | Additional config |
|---|
| Server-rendered, path-mounted | None — basePath scopes _next/* automatically |
| Server-rendered, root-served | assetPrefix: '/_zones/<repo-name>' (zone has no basePath, so _next/* would collide with the host without a prefix) |
Static export (output: 'export'), either pattern | Phase-gated assetPrefix: '/_zones/<repo-name>' + vercel.json self-rewrite |
Two valid Next.js zone patterns
Both patterns are endorsed by the official docs — pick the one that fits the zone's path shape.
- Path-mounted zone:
basePath: '/us/my-tool'. Hardcoded string matching the public path. Used by the official Next.js with-zones example. Default for single-path zones — i.e. when the zone owns exactly one public path that matches the repo name in kebab case.
- Root-served zone: no
basePath; host rewrites map each public path to the zone's root. Used by the Next.js multi-zones guide's own example (the blog zone uses assetPrefix: '/blog-static' with no basePath). Requires assetPrefix: '/_zones/<repo-name>' so the zone's _next/static/* assets do not collide with the host or other zones. Required when one zone owns multiple public paths, since basePath accepts only one literal string. Production reference: household-api-docs (serves both /us/api and /uk/api from one deployment via [countryId] dynamic routes).
The Next.js multi-zones guide states only one cross-zone constraint: "URL paths should be unique to a zone." A zone can own multiple paths; two zones can't share one.
Why no env-driven basePath (e.g. process.env.NEXT_PUBLIC_BASE_PATH ?? '/us/my-tool')?
Neither documented pattern uses an env override. The intended dev workflow is to hit the zone at localhost:<port>/<basePath> directly (path-mounted) or localhost:<port>/<public-path> (root-served), or to run the host with its rewrites() destination pointed at localhost:<zone-port> for end-to-end local development. There is no docs-endorsed "drop the basePath in dev" escape hatch, and adding one hides basePath bugs that would otherwise surface in dev. A few existing zones (keep-your-pay-act, oregon-kicker-refund, working-parents-tax-relief-act) still use this pattern; they're tracked for retrofit but are not multizone blockers.
Host rewrite shape depends on the chosen pattern — see the "Canonical zone config" sections below; each shows the matching host rewrite inline.
What each config controls
basePath: route URLs — page paths, next/link hrefs, API route paths. Auto-scopes _next/static/ assets for server-rendered builds.
assetPrefix: static asset URLs only — _next/static/*, next/image, next/script. Needed when basePath can't scope assets automatically (static exports) or when the zone has no basePath (root-served).
Both may coexist — they govern different URL types and never conflict.
Canonical zone config — server-rendered (common case)
const nextConfig: NextConfig = {
basePath: '/us/my-tool',
};
export default nextConfig;
{ source: '/us/my-tool', destination: 'https://my-tool.vercel.app/us/my-tool' },
{ source: '/us/my-tool/:path*', destination: 'https://my-tool.vercel.app/us/my-tool/:path*' },
Canonical zone config — static export
Static exports need three coordinated pieces — each covers a different environment; omitting any one breaks that environment. (The root-served variant of this pattern, with no basePath, is in production at PolicyEngine/household-api-docs.)
1. Zone's next.config.mjs — phase-gated assetPrefix
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants.js';
export default function nextConfig(phase) {
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
return {
output: 'export',
basePath: '/us/my-tool',
assetPrefix: isDev ? undefined : '/_zones/my-tool',
trailingSlash: true,
};
}
Export a function (not an object) so Next.js passes the build phase. PHASE_DEVELOPMENT_SERVER fires during next dev; all other phases (next build, next start) do not.
2. Zone's vercel.json — self-rewrite
{
"rewrites": [
{ "source": "/_zones/my-tool/_next/:path*", "destination": "/_next/:path*" }
]
}
Lets the zone's own Vercel deployment serve its built, prefixed assets when hit directly at its .vercel.app URL (e.g. for zone-only previews).
3. Host's website/next.config.ts — asset rewrite
{ source: '/_zones/my-tool/:path*', destination: 'https://my-tool.vercel.app/_zones/my-tool/:path*' },
Plus the two route rewrites (same as server-rendered). Total: three rewrites for static-export zones, two for server-rendered.
Why all three pieces are needed
| Environment | Who serves the request | Which piece fixes it |
|---|
bun dev at localhost:3001/us/my-tool | next dev — ignores vercel.json | Phase gate drops the prefix |
Zone-only preview at my-tool.vercel.app/us/my-tool | Zone's own Vercel deploy | vercel.json self-rewrite strips the prefix internally |
Prod via host at policyengine.org/us/my-tool | Host website, proxying to zone | Host asset rewrite forwards /_zones/* to the zone |
Drop any one and the corresponding environment 404s its JS/CSS.
Mandatory rules
- Use
beforeFiles in the host. Zone rewrites must take priority over the website's dynamic [slug] routes.
- Cross-zone navigation uses
<a>, not <Link>. next/link does client-side routing and breaks across zones.
- No absolute-URL
assetPrefix. Always use /_zones/<repo-name> so the zone isn't hardcoded to a specific Vercel domain.
- Static-export zones gate
assetPrefix on PHASE_DEVELOPMENT_SERVER. See template above. Unconditional assetPrefix breaks next dev.
- Shared chrome via
@policyengine/ui-kit (Header, Footer) so zones look native to the host.
Icons and favicons
The Next.js docs recommend the icon file convention over metadata.icons in general ("the file-based API will automatically generate the correct metadata for you"). Under multi-zone that recommendation becomes load-bearing, because metadata.icons URLs aren't basePath-prefixed (see below).
Drop the icon image directly into app/:
app/icon.{ico,jpg,jpeg,png,svg} → emitted as <link rel="icon">
app/apple-icon.{jpg,jpeg,png} (PNG only — Safari ignores SVG; recommended size 180×180) → emitted as <link rel="apple-touch-icon">
Next.js generates the link tag with the basePath already prefixed, plus a content hash and the right MIME type, with no extra config.
Do not put icon URLs in metadata.icons (e.g. icons: { icon: '/favicon.svg' }). The Next.js docs don't explicitly address the multi-zone interaction, but those URLs are not auto-prefixed with basePath — see vercel/next.js#61487 (closed as not planned). Under multi-zone, an icon defined in metadata.icons resolves at the host root (policyengine.org/favicon.svg) instead of under the zone's basePath, and 404s if the host doesn't serve a file at that path.
Working example: policyengine-taxsim (dashboard/src/app/icon.png).
Zone path naming
- Country-scoped tools:
/us/<kebab-name> or /uk/<kebab-name> (e.g. /us/watca, /us/keep-your-pay-act)
- Cross-country content:
/<kebab-name> (e.g. /slides, /plugin-blog)
- Embedded-only variants: append
/embed (e.g. /us/california-wealth-tax/embed)
The zone path must match the repo name's kebab-case form unless there's a strong reason to differ.
New-zone checklist
Retrofitting existing tools
Run /audit-multizone <path> to validate an existing tool against these rules. The multizone-validator agent reports findings without editing.
Known nonstandard: Model docs use an absolute-URL assetPrefix pointing at its Vercel domain — migrate to /_zones/policyengine-model when touching that repo.
CRITICAL: Never hardcode computed data
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:
- Read its parameters from the repo's config files (e.g.,
reform.json)
- Use vectorized simulation where possible (multiple persons in one
Simulation call)
- Write output to a JSON/CSV file that the frontend imports
- Be re-runnable to regenerate data when parameters change
Data and computation patterns
Choose based on what the tool needs from PolicyEngine:
Pattern A: Precomputed JSON
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.
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)
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.
Pattern B: PolicyEngine API
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.
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.
Pattern C: Custom API on Modal (gateway + polling)
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.
Why three files?
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 |
Image setup (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
CountryTaxBenefitSystem()
logger.info("Models pre-loaded into image snapshot")
Worker app (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")
.run_function(snapshot_models)
.add_local_file(str(_BACKEND_DIR / "simulation.py"), remote_path="/root/simulation.py")
)
@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)
Simulation logic (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
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"])
return {"revenue_change": ..., "winners": ..., "losers": ...}
Gateway (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
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))
@app.function(image=gateway_image, memory=256)
@modal.asgi_app()
def fastapi_app():
return web_app
Frontend polling client
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();
}
React Query polling hook
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);
const submit = useQuery({
queryKey: [...queryKey, "submit"],
queryFn: async () => {
const id = await submitJob(endpoint, params);
setJobId(id);
return id;
},
enabled,
});
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:
unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET
modal deploy backend/app.py
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
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.
Modal timeout reference
| 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.
Pattern D: Precomputed CSV dashboard
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.
Scaffolding a new tool
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
app/layout.jsx
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>
);
}
app/globals.css — import ui-kit theme
@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.
Using tokens in components
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",
}}>
Legacy iframe embedding
For new tools, use multi-zone integration instead. This section covers iframe embedding, retained for legacy tools and for apps.json types that still require it (obbba-iframe, custom).
1. Register in apps.json
Add entry to website/src/data/apps.json in policyengine-app-v2:
{
"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.
If the tool needs a proxied path or nonstandard rewrite instead of a direct iframe source URL, update website/next.config.ts in policyengine-app-v2 at the same time.
2. Country detection
When embedded at /uk/my-tool, policyengine.org injects #country=uk into the iframe URL.
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.
3. URL hash synchronization
The parent app syncs the iframe hash to the browser URL bar:
const hash = `#region=CA&head=50000&spouse=40000`;
window.history.replaceState(null, "", hash);
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);
4. Share URLs
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;
}
5. Country toggle
Hide when embedded (country comes from the route):
<InputForm countries={isEmbedded ? null : COUNTRIES} ... />
Charts
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:
- Positive/bonus:
var(--chart-1)
- Negative/penalty:
var(--chart-3) or var(--destructive)
- Neutral:
var(--border)
Inverted metrics (taxes): When positive delta means bad (more taxes), pass invertDelta to your chart component to flip labels and colors.
Recharts + ui-kit tokens
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)").
Code highlighting
For tools that show code or formulas, use Prism React Renderer:
bun add prism-react-renderer
Mobile responsiveness
Use Tailwind responsive prefixes (sm:, md:, lg:) or custom media queries:
@media (max-width: 768px) { ... }
@media (max-width: 480px) {
.form-row { flex-direction: column; }
}
Key patterns:
- Collapsible sidebar with summary toggle on mobile
- Sticky first column on data tables for horizontal scroll
- Reduce chart heights on small screens
- Stack form fields vertically below 480px
Testing
bun add -D vitest
bunx vitest run
Test API responses against Python fixtures for numerical accuracy. See PolicyEngine/marriage/tests/ for examples.
Pull-request CI
Every standalone tool repo should add .github/workflows/ci.yml before launch:
name: CI
on:
pull_request:
push:
branches: [main, master]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Run PolicyEngine migration guardrails
run: |
curl -fsSL https://raw.githubusercontent.com/PolicyEngine/policyengine-skills/main/scripts/audit_next_migration.py -o /tmp/audit_next_migration.py
python3 /tmp/audit_next_migration.py --root .
- run: bun install --frozen-lockfile
- name: Run lint
run: |
if jq -e '.scripts.lint' package.json >/dev/null; then
bun run lint
else
echo "No lint script"
fi
- name: Run tests
run: |
if jq -e '.scripts.test' package.json >/dev/null; then
bun run test
else
echo "No test script"
fi
- run: bun run build
Do not leave embedded repos deploy-only or schedule-only. If the repo ships on policyengine.org, it needs pull-request validation and migration guardrails.
Frontend Verification Rules
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.
- Never claim a dev server is running without checking
lsof -i :<port>.
- You cannot visually verify a frontend. After the build passes and dev server starts, tell the user it's ready — don't claim it "looks good."
- When
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.
- If you've tried 2 things and they haven't worked, stop and ask. The user would rather hear "I'm stuck, here's what I tried" than watch increasingly desperate hacks.
Checklist for new tools
Additional for multi-zone tools (preferred)
See "New-zone checklist" in the Multi-zone integration section above.
Additional for legacy iframe tools
Related skills
policyengine-design-skill — Full token reference
policyengine-vercel-deployment-skill — Vercel deployment patterns
policyengine-app-skill — app-v2 development (different from standalone tools)