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.

Checks before a commit

npm run typecheck    # tsc --noEmit
npm run lint         # eslint .
npm test             # 490 tests

npm run lint was next lint until v1.1.0, which Next 15.5 deprecated: with no ESLint installed and no config in the repository it answered by offering to set one up interactively and linted nothing. It had never run. It is a flat config now — eslint.config.mjs, the only kind ESLint 9 reads — extending next/core-web-vitals and next/typescript through FlatCompat, which is what create-next-app generates for Next 15.

Two rules are relaxed, both for deliberate patterns:

  • no-explicit-any is off for tests/ and scripts/. A test asserting against the JSON a route returned would otherwise need a parallel set of response interfaces, and would then be checking itself against those rather than checking the route. It stays on for app/ and lib/.
  • no-html-link-for-pages is off. The app navigates with plain <a href> at all 25 internal links and imports next/link nowhere, so the rule reports one decision 25 times. It is right that <Link> would prefetch and navigate client-side — making that switch is its own change, and deleting the disabled rule is how you would start it.

Vercel image optimization is ruled out by configuration

The Hobby plan meters /_next/image, and nothing here needs it: the artwork is inline SVG, the radicals are SVG masks served straight from /public, and the mnemonic illustrations are remote WaniKani URLs we neither host nor resize. Nothing imports next/image, so nothing is metered.

images.unoptimized is set in next.config.mjs so that stays true by configuration rather than by nobody having reached for <Image> yet. One added later renders a plain <img> and bills nothing, instead of quietly starting a meter that is easy to miss until the quota mail arrives.

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. Production only — Preview falls back to VERCEL_URL, and local development to localhost.
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.

Preview runs against the dev branch, and must keep doing so

DATABASE_URL and DIRECT_URL are defined twice — a Production pair pointing at the production Neon branch, and a separate Preview pair pointing at the dev branch. Two records, one per environment, not one record scoped to both.

That separation is the only thing making a preview safe to press buttons in. “Unlock ahead of schedule” cannot be undone, and a preview sharing the production database would advance the real account’s real progression — no amount of throwing the preview away puts the SRS spacing back. If you ever see one variable listed against Production, Preview together, that is the unsafe shape and wants splitting.

To add one, in Settings → Environment Variables: un-tick Preview on the existing variable first — Vercel will not accept a second definition for an environment already claimed — then add the Preview-only value. Neon’s Vercel integration does it per-preview-branch instead, which is the better answer if previews become routine rather than occasional.

Two things that are easy to miss. The Preview database needs the migrations applied or a preview connects happily and then fails on a missing column, which reads as a code bug and is not one. And the mail variables are scoped to Preview as well, pointing at the real Resend account — nothing fires on its own, since Vercel schedules crons only on production, but the nudge endpoint on a preview URL will send real email to whatever addresses the dev branch holds.

API_BASE_URL is the only variable with a fallback chain rather than a value. apiBaseUrl() in lib/http.ts reads three sources in order: the explicit API_BASE_URL, then VERCEL_URL, then localhost.

The middle step exists because a preview has no fixed hostname to set in advance — the URL carries a per-deployment hash, so the deployment can only describe itself at runtime. Without it a preview fell back to localhost and published it: every url and pages.next_url in a response, and every link in an email it sent, pointed at the reader’s own machine. An explicit value still wins, so a custom domain is never overridden by the deployment’s own hostname.

Deploying

Push to main; Vercel builds and deploys. No workflow file, and the build is plain next build — it needs neither the database nor the network.

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.