I've been building MinnaKu, a free Japanese learning platform, for the past few months. It started as a simple vocabulary list and slowly turned into a full learning app: 50 chapters, grammar notes, kanji, reading and listening practice, quizzes, and JFT/JLPT-style exam simulations.
You can try it here: https://minnaku.com
This post is about the technical side — the stack, the architecture, and a few problems that took me longer than I'd like to admit.
The stack
- Next.js 16 (App Router, React 19, server components)
- Prisma 7 with PostgreSQL 18
- NextAuth v5 (Google + credentials)
- Backblaze B2 for assets, behind a custom domain
- Cloudflare for DNS, TLS and caching
- Brevo for transactional email
- Deployed on a small 2 vCPU / 4 GB VPS with PM2 and nginx
No Kubernetes, no serverless, no managed platform. Just a VPS, a reverse proxy, and a database.
What the app actually does
The content model is simple: a Course → Level → Lesson → Content chain, where each content row has a type (grammar, vocabulary, kanji, reading, listening, dialogue). Tabs are rendered from that type, so adding a new kind of material doesn't require a new page.
On top of that:
- A question bank with 31,000+ questions across four JFT sections (文字と語彙, 会話と表現, 聴解, 読解)
- A CSV importer and an AI-assisted importer for bulk content entry
- TTS audio generated on demand and cached in object storage
- Admin pages for content, assets, exams, and certificates
Three problems worth sharing
1. Soft 404s that returned HTTP 200
Next.js streams responses. If you call notFound() after the first byte is flushed, the status code is already committed — so the browser (and Google) receives a 200 with a "not found" body, and ISR happily caches it.
The fix was to validate the entity inside generateMetadata(), which resolves before streaming starts, and reuse the same repository call (wrapped in React cache() so it only hits the database once). Invalid slugs now return a real 404 with cache-control: private, no-cache instead of a cached 200.
2. Prerendered HTML outliving an env change
I changed the public asset host and rebuilt. The homepage still pointed at the old domain.
Reason: rsync kept .next on the server (it's built there), so the previous build's prerendered HTML survived and got reused. Clearing .next/cache at runtime is not enough — the fix is rm -rf .next before next build. Now every deploy starts from a clean build.
3. Caching TTS without burning the storage quota
Audio is generated per vocabulary item and cached as an MP3 in B2. The first prewarm attempt "failed" on 5,311 items. The TTS provider was fine — the problem was one HeadObject per item, and B2's free tier only allows ~2,500 Class B transactions per day.
Switching to a single paginated ListObjectsV2 to build the "already exists" set brought a full run down to 7 seconds and zero failures. If you cache generated media, list once instead of checking per object.
What I'd do differently
- Start with real 404s and clean builds. Both bugs were invisible locally and only showed up in production.
-
Put limits on the process, not just the platform. PM2 was watching
npm start(a wrapper) instead of the actual Next server, somax_memory_restartnever fired. Launchingnode_modules/next/dist/bin/nextdirectly made memory limits and metrics meaningful. - Treat crawlers as traffic. After submitting the sitemap, search bots started fetching hundreds of server-rendered pages at once. Blocking worthless SEO scrapers at the reverse proxy and capping the Node heap kept the box stable at ~350–450 MB.
Try it
The site is free and doesn't require an account to browse the material:
If you're learning Japanese — or building something similar with Next.js and Prisma — I'd love feedback, especially on the exam simulation flow and the reading/listening sections.
Thanks for reading.
Top comments (5)
A VPS, a reverse proxy and a database serving 31,000 questions is a nice reminder of how far simple goes. Rendering tabs from the content type is a clean choice too. One thing worth checking with AI-assisted imports for exam questions: a second pass that verifies each answer key, since one wrong key in a mock exam costs a learner more than a missing question.
The data agrees with you: 31k+- questions, all published. AI imports land as DRAFT first, but after that the only gate is a human skim — which is exactly where a wrong key survives.
My plan is a blind re-solve pass: model answers each item without seeing the stored key, then diff both answers and flag disagreements, plus structural checks (one correct option, key exists in options, no duplicates, passage/transcript present for reading/listening).
Would you gate publishing on a clean pass, or publish and treat mismatches as a review queue? Gating is safer, but it would have blocked most of my existing bank.
I'd split it: gate new imports on a clean pass, and treat the existing bank as a review queue sorted by risk. Structural failures (no key, two correct options, missing passage) are cheap to fix and should block. Re-solve disagreements on old items go to a queue, mock exams first since a wrong key costs most there. That way you don't freeze the bank, but nothing new adds to the debt.
A Japanese learning platform benefits from treating progress as more than completed lessons. Keeping attempts, reading variants, and review intervals as first-class records makes spaced repetition explainable and lets learners recover when an answer was technically correct but linguistically unnatural.
Agreed, and that's roughly how the schema is shaped already: every answer stores questionId + questionVersionId, the raw answer, correctness, score and timestamp, so retries append rather than overwrite. SRS state (dueAt, repetitionCount, srsState) sits on the content item, not on "lesson completed."
The gap is exactly your last point, isCorrect can't represent "technically right, linguistically unnatural." I'd like scored bands (accepted / acceptable-with-note / rejected) plus the matched reading variant, so the interval and the feedback stay explainable.
Would you model SRS per content item or per (content, reading) pair? The latter feels more correct for multi-reading kanji, but the review load grows fast.