PolicyEngine app v2
PolicyEngine/policyengine-app-v2 is the frontend served at policyengine.org. It is
mid-migration: a Next.js 15 App Router host lives in website/ and is the live apex,
while a legacy Vite SPA in app/ still owns the calculator surface and is being ported
page by page. Verify the current stack from package.json files before trusting any summary
— this repo moves fast.
Monorepo layout
Bun workspaces + Turbo (packageManager: bun@1.2.21, root turbo.json). Workspaces:
packages/*, app, website, calculator-app.
| Path |
What |
Bundler |
website/ |
@policyengine/website — Next.js 15 App Router host (live apex, marketing/research/tools) |
Next 15 + Turbopack |
app/ |
policyengine-app-v2 — legacy Vite SPA (calculator: policies, households, reports, simulations) |
Vite 6 |
calculator-app/ |
standalone calculator workspace |
Vite |
packages/ |
shared workspace packages |
— |
Root scripts run through Turbo: bun run build → turbo run build; bun run dev runs the
Next host (scripts/dev-server-next.mjs); bun run dev:legacy runs the Vite app.
Tech stack (verified from package.json)
There is no third-party component framework — UI is built from radix-ui primitives plus
local components. Do not add one.
website/: next ^15.3.3, radix-ui ^1.4.3, @tabler/icons-react, tailwindcss v4
(@tailwindcss/postcss), class-variance-authority, react 19, react-plotly.js (maps),
framer-motion, fuse.js, react-markdown.
app/: vite 6, react-router-dom 7, @tanstack/react-query, @reduxjs/toolkit +
react-redux, recharts ^3.7.0, @tabler/icons-react, tailwindcss v4
(@tailwindcss/vite), Storybook.
- Both pin
@policyengine/ui-kit ^0.4.0. See the policyengine-design skill for its API.
useDisclosure / useMediaQuery / useViewportSize in app/src/hooks/ are local
reimplementations — not a dependency.
App Router page pattern (website/)
Pages live under website/src/app/[countryId]/<slug>/. The convention is a server
page.tsx (exports metadata, awaits params) that renders a *Client.tsx client
component:
// website/src/app/[countryId]/ai-agents/page.tsx (server)
import type { Metadata } from "next";
import ClaudePluginClient from "./ClaudePluginClient";
export const metadata: Metadata = { title: "Claude plugin", description: "..." };
export default async function Page({ params }: { params: Promise<{ countryId: string }> }) {
const { countryId } = await params; // params is a Promise in Next 15
return <ClaudePluginClient countryId={countryId} />;
}
Styling is a hybrid: Tailwind v4 utilities use a tw: prefix
(className="tw:block tw:no-underline") and inline styles pull from JS design tokens
(import { colors, spacing, typography } from "@/designTokens"). @/components/ui exports
local Text / Title / etc. The JS tokens are a local shim: app/src/designTokens/
(colors.ts, spacing.ts, typography.ts) is a runtime object kept in sync with ui-kit's
theme.css during migration, and website/src/designTokens re-exports it. Never hardcode
hex — use the token.
Multizone routing
New cross-app rewrites go in website/next.config.ts, not the root vercel.json (which
is legacy: favicons, SPA catch-all, pre-multizone proxies). External Next tools are
registered in website/src/data/appZoneRoutes.ts (appZoneRewrites, spread into
rewrites().beforeFiles so zones beat the dynamic [slug] route). afterFiles proxies
Modal/Vercel/GitHub-Pages apps (tracker, slides, taxsim, model docs). A CI guard fails PRs
that add country-prefixed *.vercel.app rewrites to vercel.json. Full zone mechanics
(basePath, assetPrefix, self-rewrites) are in the policyengine-tools skill.
Calling the API
The legacy calculator calls the v1 API (app/src/constants.ts:
BASE_URL = 'https://api.policyengine.org'). Verified shapes:
GET {BASE_URL}/{country}/household/{id} and .../household/{id}/policy/{policyId} — household calc
POST {BASE_URL}/{country}/household — create household
GET {BASE_URL}/{country}/economy/{reformPolicyId}/over/{baselinePolicyId}?region=&time_period=&dataset=
— society-wide; async/polling (status: "computing" | "ok" | "error", queue_position,
average_time, result)
An api-v2 alpha adapter lives in app/src/api/v2/ (API_V2_BASE_URL = process.env.NEXT_PUBLIC_API_V2_URL || 'https://v2.api.policyengine.org', endpoints per
policyengine-api-v2-alpha PR #77). It is async job + poll, and CRUD for households/policies/
simulations. Variation/axes calls are not in v2 and remain on v1. See the
policyengine-api skill for both APIs.
Verification discipline
curl is NOT verification. An SPA (and a streamed Next page) returns a 200 HTML shell
whether or not React renders. The reliable compile check is bun run build (catches
import/type/missing-dep errors).
- You cannot visually verify a frontend. After a green build and a running dev server,
tell the user it is ready to check in the browser — never claim it "looks good."
- Before claiming the dev server is up, check the port:
lsof -i :3000 (Next host) or
lsof -i :5173 (Vite app). Do not assume.
- When
bun install fails, try at most 2 fixes, then ask. Never rm -rf node_modules,
hand-extract tarballs, or edit the lockfile.
- Sentence case on all UI text ("Your saved policies", not "Your Saved Policies").
Exceptions: proper nouns (PolicyEngine), acronyms (IRS), official names (Child Tax Credit).
Key files
| File |
Purpose |
website/next.config.ts |
Multizone rewrites, redirects, icon rewrites |
website/src/data/appZoneRoutes.ts |
Registry of external Next zones |
website/src/app/[countryId]/<slug>/page.tsx |
Server page + client split |
app/src/constants.ts |
BASE_URL (v1 API) |
app/src/api/ and app/src/api/v2/ |
v1 fetchers and v2 alpha adapter |
app/src/designTokens/ |
Local JS token shim (re-exported by website) |
vercel.json (root) |
Legacy config — do not add new zone rewrites here |
Related skills
- policyengine-tools — standalone embedded tools + full multizone zone config
- policyengine-design — ui-kit 0.4.0 components and design tokens
- policyengine-api — v1 and v2 API surfaces
- policyengine-writing — content and copy style
1---2name: policyengine-app3description: Load when developing policyengine-app-v2 — the site served at policyengine.org. Covers the bun/turbo monorepo, the Next.js 15 App Router host in website/, the legacy Vite SPA in app/ being migrated, ui-kit + designTokens theming, multizone rewrites, and how the frontend calls the PolicyEngine API. Triggers: policyengine-app-v2, policyengine.org, app-v2, website host, App Router page, server component split, appZoneRoutes, multizone rewrite, designTokens, BASE_URL, household calc from the app, economy over baseline, calculator app, bun run build. NOT for: building a standalone embedded tool (use policyengine-tools), design tokens in isolation (policyengine-design), or the API server itself (policyengine-api).4---56# PolicyEngine app v278`PolicyEngine/policyengine-app-v2` is the frontend served at **policyengine.org**. It is9mid-migration: a **Next.js 15 App Router host lives in `website/`** and is the live apex,10while a **legacy Vite SPA in `app/`** still owns the calculator surface and is being ported11page by page. Verify the current stack from `package.json` files before trusting any summary12— this repo moves fast.1314## Monorepo layout1516Bun workspaces + Turbo (`packageManager: bun@1.2.21`, root `turbo.json`). Workspaces:17`packages/*`, `app`, `website`, `calculator-app`.1819| Path | What | Bundler |20|---|---|---|21| `website/` | `@policyengine/website` — Next.js 15 App Router **host** (live apex, marketing/research/tools) | Next 15 + Turbopack |22| `app/` | `policyengine-app-v2` — legacy Vite SPA (calculator: policies, households, reports, simulations) | Vite 6 |23| `calculator-app/` | standalone calculator workspace | Vite |24| `packages/` | shared workspace packages | — |2526Root scripts run through Turbo: `bun run build` → `turbo run build`; `bun run dev` runs the27Next host (`scripts/dev-server-next.mjs`); `bun run dev:legacy` runs the Vite app.2829## Tech stack (verified from package.json)3031There is **no third-party component framework** — UI is built from radix-ui primitives plus32local components. Do not add one.3334- `website/`: `next` ^15.3.3, `radix-ui` ^1.4.3, `@tabler/icons-react`, `tailwindcss` v435 (`@tailwindcss/postcss`), `class-variance-authority`, `react` 19, `react-plotly.js` (maps),36 `framer-motion`, `fuse.js`, `react-markdown`.37- `app/`: `vite` 6, `react-router-dom` 7, `@tanstack/react-query`, `@reduxjs/toolkit` +38 `react-redux`, `recharts` ^3.7.0, `@tabler/icons-react`, `tailwindcss` v439 (`@tailwindcss/vite`), Storybook.40- Both pin **`@policyengine/ui-kit` ^0.4.0**. See the policyengine-design skill for its API.4142`useDisclosure` / `useMediaQuery` / `useViewportSize` in `app/src/hooks/` are local43reimplementations — not a dependency.4445## App Router page pattern (website/)4647Pages live under `website/src/app/[countryId]/<slug>/`. The convention is a **server48`page.tsx`** (exports `metadata`, awaits `params`) that renders a **`*Client.tsx`** client49component:5051```tsx52// website/src/app/[countryId]/ai-agents/page.tsx (server)53import type { Metadata } from "next";54import ClaudePluginClient from "./ClaudePluginClient";55export const metadata: Metadata = { title: "Claude plugin", description: "..." };56export default async function Page({ params }: { params: Promise<{ countryId: string }> }) {57 const { countryId } = await params; // params is a Promise in Next 1558 return <ClaudePluginClient countryId={countryId} />;59}60```6162Styling is a **hybrid**: Tailwind v4 utilities use a `tw:` prefix63(`className="tw:block tw:no-underline"`) and inline styles pull from JS design tokens64(`import { colors, spacing, typography } from "@/designTokens"`). `@/components/ui` exports65local `Text` / `Title` / etc. The JS tokens are a **local shim**: `app/src/designTokens/`66(`colors.ts`, `spacing.ts`, `typography.ts`) is a runtime object kept in sync with ui-kit's67`theme.css` during migration, and `website/src/designTokens` re-exports it. Never hardcode68hex — use the token.6970## Multizone routing7172New cross-app rewrites go in **`website/next.config.ts`**, not the root `vercel.json` (which73is legacy: favicons, SPA catch-all, pre-multizone proxies). External Next tools are74registered in **`website/src/data/appZoneRoutes.ts`** (`appZoneRewrites`, spread into75`rewrites().beforeFiles` so zones beat the dynamic `[slug]` route). `afterFiles` proxies76Modal/Vercel/GitHub-Pages apps (tracker, slides, taxsim, model docs). A CI guard fails PRs77that add country-prefixed `*.vercel.app` rewrites to `vercel.json`. Full zone mechanics78(basePath, assetPrefix, self-rewrites) are in the policyengine-tools skill.7980## Calling the API8182The legacy calculator calls the **v1 API** (`app/src/constants.ts`:83`BASE_URL = 'https://api.policyengine.org'`). Verified shapes:8485- `GET {BASE_URL}/{country}/household/{id}` and `.../household/{id}/policy/{policyId}` — household calc86- `POST {BASE_URL}/{country}/household` — create household87- `GET {BASE_URL}/{country}/economy/{reformPolicyId}/over/{baselinePolicyId}?region=&time_period=&dataset=`88 — society-wide; **async/polling** (`status: "computing" | "ok" | "error"`, `queue_position`,89 `average_time`, `result`)9091An **api-v2 alpha adapter** lives in `app/src/api/v2/` (`API_V2_BASE_URL =92process.env.NEXT_PUBLIC_API_V2_URL || 'https://v2.api.policyengine.org'`, endpoints per93policyengine-api-v2-alpha PR #77). It is async job + poll, and CRUD for households/policies/94simulations. Variation/axes calls are **not** in v2 and remain on v1. See the95policyengine-api skill for both APIs.9697## Verification discipline9899- **`curl` is NOT verification.** An SPA (and a streamed Next page) returns a 200 HTML shell100 whether or not React renders. The reliable compile check is **`bun run build`** (catches101 import/type/missing-dep errors).102- **You cannot visually verify a frontend.** After a green build and a running dev server,103 tell the user it is ready to check in the browser — never claim it "looks good."104- Before claiming the dev server is up, check the port: `lsof -i :3000` (Next host) or105 `lsof -i :5173` (Vite app). Do not assume.106- When `bun install` fails, try at most 2 fixes, then ask. Never `rm -rf node_modules`,107 hand-extract tarballs, or edit the lockfile.108- **Sentence case** on all UI text ("Your saved policies", not "Your Saved Policies").109 Exceptions: proper nouns (PolicyEngine), acronyms (IRS), official names (Child Tax Credit).110111## Key files112113| File | Purpose |114|---|---|115| `website/next.config.ts` | Multizone rewrites, redirects, icon rewrites |116| `website/src/data/appZoneRoutes.ts` | Registry of external Next zones |117| `website/src/app/[countryId]/<slug>/page.tsx` | Server page + client split |118| `app/src/constants.ts` | `BASE_URL` (v1 API) |119| `app/src/api/` and `app/src/api/v2/` | v1 fetchers and v2 alpha adapter |120| `app/src/designTokens/` | Local JS token shim (re-exported by website) |121| `vercel.json` (root) | Legacy config — do not add new zone rewrites here |122123## Related skills124125- policyengine-tools — standalone embedded tools + full multizone zone config126- policyengine-design — ui-kit 0.4.0 components and design tokens127- policyengine-api — v1 and v2 API surfaces128- policyengine-writing — content and copy style