Developing policyengine-app-v2 — the main React frontend for 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.
Architecture and patterns for developing the main PolicyEngine web application at policyengine.org.
Repository: PolicyEngine/policyengine-app-v2
policyengine-app-v2/
├── packages/
│ └── design-system/ # @policyengine/design-system (npm)
├── app/ # Main Vite application
│ ├── src/
│ │ ├── pages/ # Page components (*.page.tsx)
│ │ ├── components/ # Shared UI (charts, layouts, modals)
│ │ ├── routing/ # Guards, router config
│ │ ├── hooks/ # Custom React hooks
│ │ ├── designTokens/ # Re-exports from design-system
│ │ ├── styles/ # Mantine theme, global PostCSS
│ │ ├── data/ # Static data (apps.json, posts/)
│ │ ├── adapters/ # API fetch wrappers
│ │ ├── api/ # React Query hooks
│ │ ├── contexts/ # React Context providers
│ │ ├── types/ # TypeScript interfaces
│ │ └── utils/ # Formatters, helpers
│ ├── public/ # Static assets (logos, post images)
│ └── vite.config.mjs
├── turbo.json
└── package.json # Bun workspaces root
| Layer | Technology |
|---|---|
| Package manager | Bun (primary), npm fallback |
| Build | Vite + Turbo |
| UI framework | Mantine v8 |
| Routing | React Router v7 (createBrowserRouter) |
| Charts | Recharts (standard), Plotly (maps only) |
| Server state | React Query |
| Design tokens | @policyengine/design-system |
| Language | TypeScript |
| Formatting | Prettier + ESLint |
| Testing | Vitest |
VITE_APP_MODE controls which entry point builds:
website — Full policyengine.org (pages, blog, research, embedded tools)calculator — Standalone calculator at app.policyengine.orgbun install # Install dependencies
bun run dev # Dev server (builds design-system first)
cd app && bun run prettier -- --write . # Format before committing
bun run lint # Lint (CI uses --max-warnings 0)
bun run build # Production build
bun run test # Tests
The ONLY reliable way to verify the frontend compiles correctly is bun run build. This catches import errors, TypeScript errors, and missing dependencies.
curl is NOT verification. Vite serves an HTML shell regardless of whether React components actually render. A 200 from curl means nothing for an SPA. Never use curl output as evidence that the frontend works.
You cannot visually verify a frontend. After the build passes and dev server starts, tell the user it's ready for them to check in the browser. Do not claim it "looks good."
Before making any claim about dev server status, check with lsof -i :5173. Do not assume it is running.
bun run build # Catches all compile-time errors
# If build passes and dev server needed:
cd app && VITE_APP_MODE=website ./node_modules/.bin/vite --port 5173 &
open http://localhost:5173/us # User checks visually
When bun install fails:
rm -rf node_modules without user approvalnpm pack + tar -xzf packages into node_modulesImport from @/designTokens (convenience layer that re-exports from the design-system package):
import { colors, spacing, typography } from '@/designTokens';
Never hardcode values:
// Wrong
style={{ color: '#319795', marginBottom: '16px' }}
// Correct
style={{ color: colors.primary[500], marginBottom: spacing.lg }}
See policyengine-design-skill for the full token reference.
All UI uses Mantine v8. Key components:
import { Stack, Group, Text, Button, Paper } from '@mantine/core';
import { colors, spacing } from '@/designTokens';
function PolicyCard({ title, description, onEdit }) {
return (
<Paper p={spacing.lg} withBorder>
<Stack gap={spacing.sm}>
<Text fw={600}>{title}</Text>
<Text c={colors.text.secondary} fz="sm">{description}</Text>
<Button variant="outline" onClick={onEdit}>Edit</Button>
</Stack>
</Paper>
);
}
| Component | Usage |
|---|---|
Stack, Group, Box | Layout |
Text, Title | Typography |
Button, ActionIcon | Controls |
Paper, Card | Containers |
Table, Modal, Tooltip | Complex UI |
TextInput, Select, NumberInput | Forms |
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { ChartContainer, ChartWatermark, ImpactTooltip } from '@/components/charts';
import { colors } from '@/designTokens';
function RevenueChart({ data }) {
return (
<ChartContainer title="Revenue impact" csvData={data}>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data}>
<XAxis dataKey="name" />
<YAxis tickFormatter={(v) => v.toLocaleString('en-US', {
style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1,
})} />
<Tooltip content={<ImpactTooltip />} />
<Bar dataKey="value" fill={colors.primary[500]} />
</BarChart>
</ResponsiveContainer>
<ChartWatermark />
</ChartContainer>
);
}
| Meaning | Token |
|---|---|
| Primary data | colors.primary[500] |
| Secondary | colors.gray[400] |
| Positive/gains | colors.success |
| Negative/losses | colors.gray[600] |
| Error | colors.error |
Plotly is only used for geographic visualizations (choropleths, hex maps). All other charts use Recharts.
| Component | Purpose |
|---|---|
ChartContainer | Card wrapper with title, CSV download |
ChartWatermark | PolicyEngine logo below chart |
ImpactTooltip | Formatted hover tooltip |
ImpactBarLabel | Values above/below bars |
Routes in app/src/WebsiteRouter.tsx:
/:countryId/
├── (StaticLayout)
│ ├── home (index)
│ ├── research/:slug (blog posts)
│ ├── brand/*, team, donate
│ └── model
├── (AppLayout)
│ └── :slug → AppPage.tsx (apps.json)
└── (full-page embeds)
CountryGuardSimple — Validates countryId, redirects to defaultCountryAppGuard — Validates slug+countryId for appsapp/src/pages/MyPage.page.tsxWebsiteRouter.tsx under appropriate layoutuseParams() for countryIdInteractive tools register in app/src/data/apps/apps.json and render via AppPage.tsx:
{
"type": "iframe",
"slug": "marriage",
"title": "Marriage calculator",
"source": "https://marriage-zeta-beryl.vercel.app/",
"countryId": "us",
"displayWithResearch": true
}
See policyengine-interactive-tools-skill for the full embedding pattern.
Policies, Reports, Simulations, and Populations follow a shared pattern using IngredientReadView and RenameIngredientModal. See app/.claude/skills/ingredient-patterns.md for details.
Markdown files in app/src/data/posts/articles/. Metadata in posts.json.
Strictly enforced everywhere:
// Correct
<Title order={2}>Your saved policies</Title>
// Wrong
<Title order={2}>Your Saved Policies</Title>
Exceptions: proper nouns (PolicyEngine), acronyms (IRS), official names (Child Tax Credit).
main via Vercelpolicyengine.org (website), app.policyengine.org (calculator)policy-engine scope| File | Purpose |
|---|---|
app/src/WebsiteRouter.tsx | Main routes |
app/src/pages/AppPage.tsx | Renders embedded apps |
app/src/data/apps/apps.json | Tool registry |
app/src/designTokens/ | Token imports |
app/src/components/charts/ | Chart components |
packages/design-system/ | Token source of truth |
app/.claude/skills/ | Local skills (design-tokens, chart-standards, ingredient-patterns) |
| Domain | Purpose |
|---|---|
policyengine.org | Marketing website, research, blog |
app.policyengine.org | Calculator app (policies, households, reports) |
app.policyengine.org/:countryId/ # Dashboard
app.policyengine.org/:countryId/policies # Saved policies
app.policyengine.org/:countryId/policies/create # Policy builder
app.policyengine.org/:countryId/households # Saved households
app.policyengine.org/:countryId/households/create # Household builder
app.policyengine.org/:countryId/reports # Saved reports
app.policyengine.org/:countryId/reports/create # Report builder
app.policyengine.org/:countryId/simulations # Saved simulations
app.policyengine.org/:countryId/simulations/create # Simulation builder
app.policyengine.org/:countryId/report-output/:reportId # Report output (overview)
app.policyengine.org/:countryId/report-output/:reportId/:subpage/:view # Specific chart
Report output subpages and views:
/report-output/:reportId/budget # Budget overview
/report-output/:reportId/distributional/incomeDecile # Distributional by income
/report-output/:reportId/distributional/wealthDecile # Distributional by wealth
/report-output/:reportId/winners-losers/incomeDecile # Winners/losers by income
/report-output/:reportId/winners-losers/wealthDecile # Winners/losers by wealth
/report-output/:reportId/poverty/age # Poverty by age
/report-output/:reportId/poverty/gender # Poverty by gender
/report-output/:reportId/poverty/race # Poverty by race (US only)
/report-output/:reportId/deep-poverty/age # Deep poverty by age
/report-output/:reportId/deep-poverty/gender # Deep poverty by gender
/report-output/:reportId/inequality # Inequality measures
Country IDs: us, uk, ca, ng, il
policyengine.org/:countryId/ # Country home
policyengine.org/:countryId/research # Research index
policyengine.org/:countryId/research/:slug # Research article
policyengine.org/:countryId/blog # Blog index
policyengine.org/:countryId/blog/:postName # Blog post
policyengine.org/:countryId/model # Policy model explorer
policyengine.org/:countryId/:slug # Embedded app (from apps.json)
The old policyengine-app (v1) used a different URL pattern that no longer works:
# WRONG — v1 format, returns "App not found"
policyengine.org/us/policy?reform=73278&baseline=2®ion=enhanced_us&timePeriod=2025
policyengine.org/us/reform/2/280039/over/2/us?focus=policyOutput.winnersAndLosers.incomeDecile
Always use app.policyengine.org for calculator functionality.
policyengine-design-skill — Full token referencepolicyengine-interactive-tools-skill — Building standalone toolspolicyengine-vercel-deployment-skill — Deployment patternspolicyengine-writing-skill — Content stylepolicyengine-api-skill — Backend API