kanji-buddy
Architecture
One Next.js app is the API, the web client and the scheduled job. Why that's fewer moving parts than it sounds.
The stack
| Piece | Choice | Why |
|---|---|---|
| Framework | Next.js 15.5 (App Router) | Route handlers and pages in one project, so the API and the client that consumes it deploy together and can never drift out of step. |
| UI | React 19 | Comes with Next. No component library — the visual language is copied from WaniKani, and a library would have been fought rather than used. |
| Database | Neon Postgres 18 | Real Postgres, generous free tier, and branching that clones the whole database instantly. Scales to zero between sessions. |
| Query layer | Drizzle ORM 0.36 | Types come from the schema rather than a generator step, and the SQL it emits is the SQL you wrote. The aggregate queries here needed FILTER and window-ish work that a heavier ORM would have hidden. |
| Driver | postgres 3.4 | Talks to Neon's pooler. prepare:false is mandatory — the pooler is PgBouncer in transaction mode, which cannot do prepared statements. |
| Kana input | wanakana 5.3 | Converts romaji to kana as you type in the reading field. Solving that properly is a project in itself. |
| Validation | zod 3.25 | Request bodies only. The API is written to someone else's spec, so rejecting malformed input precisely matters. |
| Hosting | Vercel | Zero-config for Next, and the cron scheduler is part of the platform rather than another service to run. |
Exact versions
Written against next 15.5.21, react 19.2.8, drizzle-orm 0.36.4, postgres 3.4.9, zod 3.25.76, wanakana 5.3.1, @vercel/analytics 2.0.1; dev: @electric-sql/pglite 0.5.4, drizzle-kit 0.28.1, tsx 4.23.1, typescript 5.9.3. Node >=22.
One deployment, three jobs
The pages are ordinary React that fetch from /api/v1/* in the browser — the same endpoints any other client would use. Nothing renders on the server with a database in hand. That is a deliberate constraint: it keeps one honest API rather than a public one plus a privileged back door, so a bug in access control has one place to hide instead of two.
The cost of that choice
Client-side fetching means a page load is HTML, then JavaScript, then a request, then data. It is slower than server-rendering with a direct query. The dashboard is the worst case and gets its own aggregate endpoint (/api/v1/dashboard) precisely because fifteen resource requests would have been indefensible.
Where the logic lives
Route handlers are thin: authenticate, parse, call something in lib/, serialise. The interesting code — the SRS arithmetic, unlock progression, answer checking, the nudge policy — is plain functions taking a database handle as their first argument.
That shape is what makes the test suite possible. Every one of those functions runs against an in-process Postgres with no server and no network; see Testing.
Two things that shaped the design
The API is a clone, not an invention. Response envelopes, id-cursor pagination, updated_after, ETag and 304 all match WaniKani’s v2 spec. Where this app needs something WaniKani doesn’t have — the dashboard aggregate, the portal session endpoint, the nudge cron — it lives at a path that makes the difference obvious rather than bending an existing resource.
Revocation had to be immediate. Access is conditional on the portal still allowing it, which ruled out signed tokens that stay valid until they expire. Sessions are rows; revoking is a flag, and it takes effect on the next request. See Auth & access.
Geography matters more than it should
The database is in Singapore. When the Vercel function ran in Washington instead, every query crossed the Pacific at about 465ms, and the dashboard — fourteen round trips — took 31 seconds. Moving the function to the same region took it to 0.19s. Two other things contributed and are worth knowing about; they are covered in Build & deploy.