PolicyEngine standalone tools
How to build calculators, dashboards, and visualizations that deploy independently to Vercel
and slot into policyengine.org as Next.js multizones. New tools are their own repos; the
host (policyengine-app-v2/website/) proxies a URL path to each tool's Vercel deployment.
Required stack
- Next.js (App Router) — not Pages Router, not Vite as the app bundler (Vite only backs
Vitest).
next.config.ts+app/withlayout.tsx+page.tsx, TypeScript. - Tailwind v4, CSS-first — no
tailwind.config.{ts,js}.globals.cssis exactly:@import "tailwindcss"; @import "@policyengine/ui-kit/theme.css"; /* both required; Tailwind must come first */ - PostCSS is required for Next — create
postcss.config.mjswith{ plugins: { "@tailwindcss/postcss": {} } }and install@tailwindcss/postcss+postcssas devDeps. (A Vite tool uses the@tailwindcss/viteplugin instead and needs no PostCSS config.) The theme.css header itself states@tailwindcss/postcssis mandatory — the older "no postcss.config" guidance was wrong. @policyengine/ui-kit(bun add @policyengine/ui-kit) — use its components before building your own. See policyengine-design for the full 0.4.0 export list.- bun for everything (
bun install,bun run build,bunx;bun.lockcommitted). - Vercel deploy under the
policy-enginescope. Static-only tools may useoutput: 'export'; tools with a Modal backend stay server-rendered.
Scaffold: bunx create-next-app@latest my-tool --ts --app --tailwind --eslint --import-alias "@/*",
then bun add @policyengine/ui-kit and set up globals.css as above.
ui-kit components (verified 0.4.0)
Prefer these over hand-rolled equivalents; import from @policyengine/ui-kit:
| Need | Component |
|---|---|
| Page shell / layout | DashboardShell, SidebarLayout, SingleColumnLayout, InputPanel, ResultsPanel, Stack, Group, Container |
| Header + footer | Header, Footer (Header props: navItems, logoSrc, linkComponent, countries — not variant/navLinks) |
| Inputs | CurrencyInput, NumberInput, SelectInput, CheckboxInput, SliderInput, InputGroup |
| Primitives | Button, Card, Badge, Tabs, Dialog, Select, Tooltip, Alert, Switch, SegmentedControl, … |
| Display | MetricCard, SummaryText, DataTable, PolicyEngineWatermark |
| Charts | ChartContainer, PEBarChart, PELineChart, PEAreaChart, PEWaterfallChart |
| Maps | USDistrictChoroplethMap, UKConstituencyChoroplethMap, HexagonalMap, HouseholdGraph |
| Utils | formatCurrency, formatPercent, formatNumber, cn, logos |
Only build custom when nothing above fits. getCssVar() does not exist — SVG accepts var().
Multizone integration
The host proxies a path to your tool via rewrites in
policyengine-app-v2/website/next.config.ts. basePath governs route URLs;
assetPrefix governs static asset URLs (_next/static/*). Pick the zone pattern from
how many public paths the zone owns:
- Path-mounted (one public path matching the kebab repo name):
basePath: '/us/my-tool'. Server-rendered builds need noassetPrefix—basePathscopes_next/staticautomatically. - Root-served (multiple public paths, e.g.
/us/api+/uk/api): nobasePath; setassetPrefix: '/_zones/<repo-name>'so assets don't collide with the host.
Host rewrite (in beforeFiles, so it beats the dynamic [slug] route), hardcoded to the
zone's production Vercel URL:
{ 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*' },
Static-export zones (output: 'export') need three coordinated pieces — omit any and
that environment 404s its JS/CSS:
- Zone
next.config.mjsexports a function and phase-gates the prefix sonext devstill resolves assets:assetPrefix: phase === PHASE_DEVELOPMENT_SERVER ? undefined : '/_zones/my-tool', plusoutput: 'export',basePath,trailingSlash: true. - Zone
vercel.jsonwith"framework": null(the Next preset silently dropsvercel.jsonrewrites), explicitbuildCommand/outputDirectory: "out","trailingSlash": true, and self-rewrites mapping basePath routes +/_zones/<repo>/_next/*onto the export root. Use regex:path(.*)(not:path*) so trailing-slash URLs match. - Host asset rewrite:
{ source: '/_zones/my-tool/:path*', destination: 'https://my-tool.vercel.app/_zones/my-tool/:path*' }.
Mandatory rules:
- Cross-zone navigation uses
<a>, notnext/link— client-side routing breaks across zones. - Favicon via the file convention — drop
app/icon.png(andapp/apple-icon.png, PNG only, 180×180). Next auto-prefixes the basePath. Do not usemetadata.iconsURLs; they are not basePath-prefixed and 404 under multizone. - Render shared chrome from ui-kit (
Header/Footer) so the zone looks native. - App-specific provenance (policyengine.py version, static-estimate caveats) belongs in the tool's methodology footnote, never the shared header/footer.
Deployment gotchas
- Vercel auto-assigns a non-deterministic URL suffix (e.g.
marriage-zeta-beryl.vercel.app). Read the actual URL after first deploy and hardcode that in the host rewrite / apps.json — never guess it. - Deployment Protection returns 401. Custom aliases can inherit protection; use the
auto-assigned production
*.vercel.appURL as the rewrite/iframe source, and disable Deployment Protection for the project if the host proxy gets 401s. - Deploy with
vercel --prod --scope policy-engine. Ifpackage.jsonis in a subdir, set the Vercel project Root Directory in the dashboard — don't fight it with a rootvercel.jsoncd subdir &&command (the framework detector fails: "No Next.js version detected").
Legacy iframe embedding
Only for legacy tools and the obbba-iframe / custom apps.json types — new tools use
multizone. Register in policyengine-app-v2/website/src/data/apps.json (type, slug,
title, source = auto-assigned Vercel URL, countryId, and for displayWithResearch:
image, date, authors). Embedded tools read country from the injected hash
(const isEmbedded = window.self !== window.top; #country=uk) and point share URLs at
policyengine.org/{country}/{slug}, not the Vercel URL.
Data and backend patterns
Never paste ad-hoc computed numbers into source. Always: Python script → data file (JSON/CSV) → frontend imports it. Pick by need:
- A — Precomputed JSON/CSV: finite scenarios (legislative trackers, static analyses). A
scripts/*.pymicrosimulation writes topublic/data/; the frontend imports it. Zero latency, works offline. - B — v1 PolicyEngine API (
https://api.policyengine.org): household-level calc across user-entered income/family/state.POST /{country}/calculatewith a household payload, or the stateful household/economy flow. Always up to date; network latency. See policyengine-api. - C — Custom Modal backend: only when the v1 API can't do it (society-wide microsim, custom reform params, non-standard entities). Pattern below.
Pattern C: Modal gateway + worker + polling
Society-wide microsims exceed Modal's ~150 s gateway timeout, so use non-blocking job submit + poll. Two resource profiles: a cheap always-on gateway (HTTP routing only, no policyengine dep) and expensive workers that scale to zero.
Four files keep module-level imports safe (policyengine/pydantic only exist inside the Modal image, not at import time):
| File | Module-level imports | Role |
|---|---|---|
| backend/_image_setup.py | none (imports inside the fn body) | snapshot_models() run at image build for fast cold starts |
| backend/simulation.py | top-level policyengine (population) + policyengine_us/_uk Simulation (household) — captured in the snapshot | pure business logic, no Modal |
| backend/app.py | only modal | worker functions |
| backend/modal_app.py | modal, fastapi, pydantic | lightweight gateway |
Worker functions get high CPU/memory but must scale to zero — never set keep_warm or
min_containers; the image snapshot (.run_function(snapshot_models)) makes cold starts ~2 s.
# backend/app.py — workers
import modal
from _image_setup import snapshot_models
app = modal.App("my-tool-workers")
image = (modal.Image.debian_slim(python_version="3.11")
.pip_install("policyengine[us]==X.Y.Z", "pydantic") # top-level release (>=5.0.1): pins matched country model + certified data bundle
.run_function(snapshot_models)
.add_local_file("backend/simulation.py", remote_path="/root/simulation.py"))
@app.function(image=image, cpu=8.0, memory=32768, timeout=3600) # no keep_warm / min_containers
def compute_statewide(params: dict) -> dict:
from simulation import run_statewide
return run_statewide(params)
Society-wide endpoints in simulation.py compute through the managed path — import policyengine as pe; sim = pe.us.managed_microsimulation(reform=...) (same weighted
MicroSeries surface: .calc() US / .calculate() UK; provenance on
sim.policyengine_bundle) — never a directly-imported country-package Microsimulation,
whose default dataset can lag the certified bundle.
Household-level endpoints may use from policyengine_us import Simulation. Pre-cache the
certified dataset by constructing the managed microsim inside snapshot_models() (UK builds
need HUGGING_FACE_TOKEN — private dataset repo). Details: the policyengine skill.
# backend/modal_app.py — gateway (spawn + poll)
import modal
from fastapi import FastAPI
app = modal.App("my-tool")
web_app = FastAPI()
@web_app.post("/submit/{endpoint}")
def submit(endpoint: str, params: dict):
fn = modal.Function.from_name("my-tool-workers", "compute_statewide")
return {"job_id": fn.spawn(params).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:
return {"status": "ok", "result": call.get(timeout=0)}
except TimeoutError:
return {"status": "computing"}
@app.function(image=modal.Image.debian_slim().pip_install("fastapi", "pydantic"), memory=256)
@modal.asgi_app() # full FastAPI app; use @modal.fastapi_endpoint for a single route
def fastapi_app():
return web_app
The frontend submits then polls with a React Query hook (refetchInterval while
status === "computing"). Deploy footgun: unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET
before modal deploy — a stale token env var silently deploys to the wrong workspace.
Deploy workers first (builds the snapshot), then the gateway; URL is
https://policyengine--my-tool-fastapi-app.modal.run. Set it as NEXT_PUBLIC_API_URL in
Vercel. Modal apps can silently disappear — if the frontend gets network errors, curl the
URL and redeploy on 404.
Charts
Prefer ui-kit's chart components (PEBarChart, PELineChart, ChartContainer) — they own
round-tick and formatting logic. When hand-rolling Recharts:
- Colors: pass
var(--chart-1)…var(--chart-5)directly to SVGfill/stroke; grid and chrome usevar(--border)/var(--foreground). Never hardcode hex. - Round ticks: set
niceTicks="snap125"on every numeric<XAxis>/<YAxis>(recharts ≥3.8 — a PolicyEngine-contributed prop; verify your resolved recharts version first, e.g. app-v2's lockfile still resolves 3.7.0 where the prop is silently ignored).snap125snaps tick steps to {1, 2, 2.5, 5}×10ⁿ for human-round labels; pair it withdomain={["auto", "auto"]}because the default[0, 'auto']domain clamps the minimum and breaks tick calculation for data that doesn't start at 0. Below 3.8, control ticks withtickFormatter,tickCount, or an explicitticks={[…]}array. - Tooltip: set
separator=": "(the default has a leading space). - Currency: sign before symbol —
-$31, never$-31. UseIntl.NumberFormat(or ui-kit'sformatCurrency), never string concatenation:const fmt = (v: number) => v.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }); - Wrap in
ResponsiveContainerwith an explicit height;dot={false}for dense lines.
See policyengine-design for the color tokens and the Plotly house style (maps/Python).
SEO (dual-mode)
Most SEO is generic; the PolicyEngine-specific part is dual-mode canonical. A tool exists
at both its *.vercel.app URL and the policyengine.org/{country}/{slug} path. Set a single
<link rel="canonical"> pointing at the policyengine.org path (the surface users, ads,
and Google should index) to avoid duplicate-content dilution. Under Next multizone the host
route is the public URL, so per-route generateMetadata (title, description, canonical, OG)
must be server-rendered — do not gate SEO tags on isEmbedded.
Verification
curl 200 does not mean the app renders — bun run build is the compile check. Check
lsof -i :<port> before claiming the dev server runs. You cannot visually verify; hand off to
the user after a green build. Sentence case on all UI text. Add PR CI (bun install, lint,
test, bun run build) before launch.
Related skills
- policyengine-app — the host site (policyengine.org) and its multizone registry
- policyengine-design — ui-kit 0.4.0 components, tokens, chart colors, Plotly style
- policyengine-api — v1/v2 API surfaces for Pattern B
- policyengine (Python) — microsimulation for Pattern A/C data scripts