Building a Japanese learning platform with Next.js 16 and Prisma 7
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 li
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.