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:3000

Point .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

VariableFor
DATABASE_URLNeon pooled string. Runtime queries.
DIRECT_URLNeon direct string. Migrations only.
DB_LABELPrinted on connect outside production, so the target is never a guess.
API_BASE_URLBuilds absolute URLs in responses and emails.
PORTAL_SERVICE_SECRETGuards the session-minting endpoint.
CRON_SECRETGuards the nudge endpoint. Vercel sends it automatically.
RESEND_API_KEY · EMAIL_FROMSending nudges.
NUDGE_UNSUBSCRIBE_SECRETSigns one-click opt-out links.
WANIKANI_TOKENOne-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

vercel.json
{ "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.

FixAfter
Reuse the database client6.5s
Batch the queries, raise the pool1.9s
Move the function to the database's region0.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 backfill

Order 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.