DEV Community

Cover image for Building a Japanese learning platform with Next.js 16 and Prisma 7
MinnaKu
MinnaKu

Posted on

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 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, so max_memory_restart never fired. Launching node_modules/next/dist/bin/next directly 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:

https://minnaku.com

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 (3)

Collapse
 
alexshev profile image
Alex Shev •

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.

Collapse
 
minnaku profile image
MinnaKu • • Edited

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.

Collapse
 
gautamtalksdev profile image
Gautam K •

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.