kanji-buddy
Build & deploy
Getting it running, getting it live, and the three things that took the dashboard from 31 seconds to 0.19.
Running it locally
npm install
cp .env.local.example .env.local # then fill in the Neon strings
npm run db:migrate
npm run dev # http://localhost:3000Point .env.local at the dev branch, never production — see Database. The server prints which one it reached on first connect.
A first-time setup also needs content and an account: fetch:wk, db:seed-srs, import:subjects, import:mnemonic-images, then create:user.
Environment
| Variable | For |
|---|---|
DATABASE_URL | Neon pooled string. Runtime queries. |
DIRECT_URL | Neon direct string. Migrations only. |
DB_LABEL | Printed on connect outside production, so the target is never a guess. |
API_BASE_URL | Builds absolute URLs in responses and emails. |
PORTAL_SERVICE_SECRET | Guards the session-minting endpoint. |
CRON_SECRET | Guards the nudge endpoint. Vercel sends it automatically. |
RESEND_API_KEY · EMAIL_FROM | Sending nudges. |
NUDGE_UNSUBSCRIBE_SECRET | Signs one-click opt-out links. |
WANIKANI_TOKEN | One-time content fetch. Local only. |
Deploying
Push to main; Vercel builds and deploys. No workflow file. The build runs fetch:radical-svgs first, which downloads the 19 character-less radical SVGs into public/ — they are never committed, so a fresh clone still produces a working app.
Set the function region to Singapore (Project Settings → Functions), matching the database. This matters more than anything else on this page. Region changes need a redeploy to take effect.
The cron
{ "crons": [{ "path": "/api/v1/internal/nudges", "schedule": "0 10 * * *" }] }Once a day, 10:00 UTC — early evening in Manila. The Hobby plan allows one cron a day, fires within the stated hour rather than on the minute, and does not retry. All fine: the daily tick only evaluates, and whether it sends is decided entirely by the policy, so drift or a missed day changes nothing.
How the dashboard got 165× faster
It took 31 seconds in production. Three separate causes, found by measuring rather than guessing.
| Fix | After |
|---|---|
| Reuse the database client | 6.5s |
| Batch the queries, raise the pool | 1.9s |
| Move the function to the database's region | 0.19s |
1. The client was never reused in production
db is a lazy Proxy whose get trap calls resolveDb() on every property access, and resolveDb cached the client only when NODE_ENV !== "production". So in production every single db.select(...) built a fresh postgres client — a full TCP and TLS handshake, per query, across the Pacific.
Proved by running identical work with only NODE_ENV differing: 1095ms development, 7949ms production. The comment already said “reuse across warm serverless invocations”; the condition just excluded the one environment that has them.
2. Thirteen round trips in a row
Only the level lookup gates anything, so the rest now go out in one batch. That alone changed nothing — the pool was max: 1, so the “parallel” queries queued on a single connection. The pool size was the real constraint: 1009ms at max: 1, 454ms at 6, 259ms at 12.
3. The function was in the wrong hemisphere
x-vercel-id: sin1::iad1::… — requests entered at the Singapore edge but executed in Washington, while the database sat in ap-southeast-1. Every round trip crossed the Pacific at about 465ms. Changing one project setting took that to ~5ms, and it was by far the largest of the three.
The lesson generalises: with a database in the same region, sequential round trips are invisible and none of this would have surfaced. Distance turns a design smell into an outage.
Getting a change to production
npm run db:generate # schema.ts → migration SQL
npm run db:migrate # dev branch
npm test # and click through the affected screens
git push # Vercel deploys
npm run db:migrate:prod # ENV_FILE=.env.production.local
npm run import:*:prod # any data backfillOrder matters. Deploy code first only when it tolerates the old schema — a nullable column it simply won’t find. If the code requires the column, migrate first: a Drizzle select() names every column, so live requests fail in the gap.
Verify the target before every production command
Each script announces where it connected:
[db] connected to ep-….neon.tech [PRODUCTION]If that says [dev branch], stop — the wrong env file was loaded.