---
title: "Development"
description: "Local setup, checks, and where to add procedures, tables and bindings."
---

## Prerequisites

- Node 24+, pnpm 12.
- A HeroUI Pro licence. `@heroui-pro/react` is a stub that downloads the licensed code during `pnpm install`. Run `npx heroui-pro@latest login` once, or set `HEROUI_AUTH_TOKEN` (a CI token from heroui.pro) in the environment of `pnpm 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:

```bash
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`.

```bash
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

```bash
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:

```bash
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.

```bash
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 under `src/server/…`, and wire it in `src/worker/rpc.ts`.
- **A table or column**: write `migrations/000N_name.sql`. For Better Auth plugin changes, run `pnpm exec tsx --tsconfig tsconfig.node.json scripts/generate-auth-schema.ts` and copy the new DDL into a migration. Alchemy applies pending migrations on deploy. Locally, run `pnpm db:migrate:local`.
- **A binding**: declare it in both `alchemy.run.ts` and `wrangler.jsonc`, type it in `src/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`.
