Development
Local setup, checks, and where to add procedures, tables and bindings.
Prerequisites
- Node 24+, pnpm 12.
- A HeroUI Pro licence.
@heroui-pro/reactis a stub that downloads the licensed code duringpnpm install. Runnpx heroui-pro@latest loginonce, or setHEROUI_AUTH_TOKEN(a CI token from heroui.pro) in the environment ofpnpm install. Never commit the downloaded artifacts.
First run
Conductor workspaces run scripts/conductor-setup.sh (from conductor.json): it links .dev.vars and .env.local from the main checkout, installs dependencies and migrates local D1. Otherwise:
pnpm install
pnpm exec wrangler d1 migrations apply DB --local # local D1 in .wrangler/state
node -e 'const {generateKeyPairSync}=require("crypto");const {publicKey,privateKey}=generateKeyPairSync("ec",{namedCurve:"prime256v1"});const p=publicKey.export({format:"jwk"}),s=privateKey.export({format:"jwk"});const b=x=>Buffer.from(x,"base64url");console.log("VAPID_PUBLIC_KEY="+Buffer.concat([Buffer.from([4]),b(p.x),b(p.y)]).toString("base64url"));console.log("VAPID_PRIVATE_KEY="+s.d)'
Put VAPID_PRIVATE_KEY and a random BETTER_AUTH_SECRET (32+ characters) in .dev.vars. Put the printed VAPID_PUBLIC_KEY in wrangler.jsonc.
pnpm dev
To fill the local database, run pnpm db:seed:local (wrangler d1 execute DB --local --file=seed/local.sql, the D1 way to run SQL against local state). It gives dev@example.com an “Acme” workspace at /acme. The workspace runs four real products under their real URLs: Cal.com, Dub, Plausible and Excalidraw, with 12 channels between them. Its customers are made-up people at real companies (maya@linear.app, kenji@stripe.com, …). It has 300 events over 30 days, denser towards now, whose metadata carries real links: GitHub commits and pull requests, dub.sh short links, referrers, Stripe invoices. It also has USD and EUR revenue with refunds, and the API key emitkit_localdevseed. Brand icons for those domains appear once PARSEW_PUBLISHABLE_KEY is set in .dev.vars. That key exists only in local D1. Running the seed again refreshes the seeded events to the current time and leaves everything else alone. The file sits outside migrations/, so deploys never apply it.
| URL | Surface |
|---|---|
| http://localhost:5173 | marketing site (only / renders in dev; other pages are prerendered at build) |
| http://app.localhost:5173 | PWA, /api/auth/*, /rpc/* |
| http://api.localhost:5173 | public API, /openapi.json, /mcp |
To skip signing in, click Continue in local dev on the sign-in page. It signs you in as dev@example.com (“Local Dev”) and creates that user the first time. It lands on the seeded Acme workspace, or on getting-started if you haven’t seeded. The button and its endpoint (POST /api/auth/dev/sign-in) only exist under pnpm dev. Builds, including the production-like run below, answer 404.
*.localhost resolves to 127.0.0.1 in browsers and curl. Local email is captured by the Workers email simulator and logged by the dev server. Local events use the D1 store (EVENT_STORE=d1).
Checks
pnpm check # Ultracite: oxlint (+ Effect plugin) and oxfmt
pnpm fix # autofix
pnpm typecheck # tsc for worker, web and node projects
pnpm build # vite build (worker + PWA + site) + marketing prerender + docs (Blume) bundled at /docs
pnpm verify # all three
All three must pass before a commit. The repository has no unit tests: behaviour is verified end to end (see e2e/README.md).
A production-like local run
vite dev runs the Worker through Vite’s module runner. To run the exact production bundle in workerd:
pnpm build
pnpm exec wrangler dev -c dist/emitkit/wrangler.json --port 8788 --persist-to .wrangler/state
# app: http://app.localhost:8788 (send Origin: http://app.localhost:5173 for auth calls)
Docs
docs is a separate Blume (Astro) site in its own workspace package. It is served at /docs on the site host.
pnpm docs:dev # Blume dev server with live reload (the URL is printed; pages live under /docs)
pnpm docs:build # static build to docs/dist; `pnpm build` runs it and copies it into the Worker's assets
Pages are Markdown or MDX under docs/content (navigation order in each folder’s meta.ts); docs/content/self-hosting holds this section. Changelog entries use type: changelog in content/changelog/. Write links as if the docs were mounted at the root (/getting-started); Blume adds /docs. The API reference is generated from openapi/openapi.json. oxfmt skips the MDX, because it breaks Blume’s ::: blocks and code groups.
Adding things
- A dashboard procedure: add it to
src/shared/rpc/contract.ts(Effect Schema input/output), implement it in the service undersrc/server/…, and wire it insrc/worker/rpc.ts. - A table or column: write
migrations/000N_name.sql. For Better Auth plugin changes, runpnpm exec tsx --tsconfig tsconfig.node.json scripts/generate-auth-schema.tsand copy the new DDL into a migration. Alchemy applies pending migrations on deploy. Locally, runpnpm db:migrate:local. - A binding: declare it in both
alchemy.run.tsandwrangler.jsonc, type it insrc/server/platform/env.ts, and wrap it in a platform service.
Patched dependencies
patches/@heroui__react@3.2.6.patch (applied by pnpm through patchedDependencies in pnpm-workspace.yaml) fixes HeroUI’s ErrorMessage inside dialogs. From react-aria-components 1.21, every Dialog provides a TextContext with only a description slot. HeroUI’s standalone ErrorMessage renders Text slot="errorMessage", which then throws Invalid slot "errorMessage". That crashed Settings whenever a Better Auth UI dialog showed a server error, such as a wrong password in “Delete account”. The patch renders a plain role="alert" element in that case. 06-account-pwa.spec.ts covers it. Drop the patch once a HeroUI release fixes it; pnpm fails the install if the patch no longer applies after a version bump.
Brand assets
The EmitKit brand kit (SVG masters, Geist fonts, colour and usage rules) is the source for every brand file. The web files are generated from its SVGs and committed:
| Files | Artwork |
|---|---|
web/public/{app,www}/favicon.svg, favicon.ico (16/32/48), favicon-96x96.png, web-app-manifest-{192,512} |
standard app icon, rounded |
apple-touch-icon.png, web-app-manifest-maskable-{192,512} |
standard app icon, square (iOS and maskable crops round it themselves) |
web/public/app/badge-96x96.png |
white mark on transparent (Android notification badges use alpha only) |
web/public/www/og-image.jpg |
the kit’s OpenGraph card; bump ?v= in web/www/head.ts when it changes |
web/public/www/email-wordmark.png, logo-emitkit.png |
midnight wordmark (emails can’t show SVG) |
docs/public/*, docs/fonts/* |
the same icons, both wordmarks, Geist |
In code, use BrandMark, Wordmark and AppIcon from @ui/brand-mark. They carry the kit’s rules: blue or white artwork only, never the name typed next to the mark, and a wordmark at least 96px wide. Emit Blue (#0A31F5) is HeroUI’s --accent, and Geist / Geist Mono are self-hosted in web/ui/fonts.