<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MinnaKu</title>
    <description>The latest articles on DEV Community by MinnaKu (@minnaku).</description>
    <link>https://dev.to/minnaku</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4227197%2Fdef008db-a27a-499b-8b1b-228be6267f9d.png</url>
      <title>DEV Community: MinnaKu</title>
      <link>https://dev.to/minnaku</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/minnaku"/>
    <language>en</language>
    <item>
      <title>Building a Japanese learning platform with Next.js 16 and Prisma 7</title>
      <dc:creator>MinnaKu</dc:creator>
      <pubDate>Wed, 04 Nov 2026 21:26:46 +0000</pubDate>
      <link>https://dev.to/minnaku/building-a-japanese-learning-platform-with-nextjs-16-and-prisma-7-4g0b</link>
      <guid>https://dev.to/minnaku/building-a-japanese-learning-platform-with-nextjs-16-and-prisma-7-4g0b</guid>
      <description>&lt;p&gt;I've been building &lt;strong&gt;MinnaKu&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;You can try it here: &lt;strong&gt;&lt;a href="https://minnaku.com" rel="noopener noreferrer"&gt;https://minnaku.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router, React 19, server components)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prisma 7&lt;/strong&gt; with &lt;strong&gt;PostgreSQL 18&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NextAuth v5&lt;/strong&gt; (Google + credentials)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backblaze B2&lt;/strong&gt; for assets, behind a custom domain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; for DNS, TLS and caching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brevo&lt;/strong&gt; for transactional email&lt;/li&gt;
&lt;li&gt;Deployed on a small 2 vCPU / 4 GB VPS with &lt;strong&gt;PM2&lt;/strong&gt; and &lt;strong&gt;nginx&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No Kubernetes, no serverless, no managed platform. Just a VPS, a reverse proxy, and a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the app actually does
&lt;/h2&gt;

&lt;p&gt;The content model is simple: a &lt;strong&gt;Course → Level → Lesson → Content&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;On top of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A question bank with &lt;strong&gt;31,000+ questions&lt;/strong&gt; across four JFT sections (文字と語彙, 会話と表現, 聴解, 読解)&lt;/li&gt;
&lt;li&gt;A CSV importer and an AI-assisted importer for bulk content entry&lt;/li&gt;
&lt;li&gt;TTS audio generated on demand and cached in object storage&lt;/li&gt;
&lt;li&gt;Admin pages for content, assets, exams, and certificates&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three problems worth sharing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Soft 404s that returned HTTP 200
&lt;/h3&gt;

&lt;p&gt;Next.js streams responses. If you call &lt;code&gt;notFound()&lt;/code&gt; &lt;em&gt;after&lt;/em&gt; the first byte is flushed, the status code is already committed — so the browser (and Google) receives a &lt;strong&gt;200 with a "not found" body&lt;/strong&gt;, and ISR happily caches it.&lt;/p&gt;

&lt;p&gt;The fix was to validate the entity inside &lt;code&gt;generateMetadata()&lt;/code&gt;, which resolves before streaming starts, and reuse the same repository call (wrapped in React &lt;code&gt;cache()&lt;/code&gt; so it only hits the database once). Invalid slugs now return a real 404 with &lt;code&gt;cache-control: private, no-cache&lt;/code&gt; instead of a cached 200.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Prerendered HTML outliving an env change
&lt;/h3&gt;

&lt;p&gt;I changed the public asset host and rebuilt. The homepage still pointed at the old domain.&lt;/p&gt;

&lt;p&gt;Reason: &lt;code&gt;rsync&lt;/code&gt; kept &lt;code&gt;.next&lt;/code&gt; on the server (it's built there), so the previous build's prerendered HTML survived and got reused. Clearing &lt;code&gt;.next/cache&lt;/code&gt; at runtime is not enough — the fix is &lt;code&gt;rm -rf .next&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;next build&lt;/code&gt;. Now every deploy starts from a clean build.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Caching TTS without burning the storage quota
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;one &lt;code&gt;HeadObject&lt;/code&gt; per item&lt;/strong&gt;, and B2's free tier only allows ~2,500 Class B transactions per day.&lt;/p&gt;

&lt;p&gt;Switching to a single paginated &lt;code&gt;ListObjectsV2&lt;/code&gt; to build the "already exists" set brought a full run down to &lt;strong&gt;7 seconds&lt;/strong&gt; and zero failures. If you cache generated media, list once instead of checking per object.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with real 404s and clean builds.&lt;/strong&gt; Both bugs were invisible locally and only showed up in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put limits on the process, not just the platform.&lt;/strong&gt; PM2 was watching &lt;code&gt;npm start&lt;/code&gt; (a wrapper) instead of the actual Next server, so &lt;code&gt;max_memory_restart&lt;/code&gt; never fired. Launching &lt;code&gt;node_modules/next/dist/bin/next&lt;/code&gt; directly made memory limits and metrics meaningful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat crawlers as traffic.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The site is free and doesn't require an account to browse the material:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://minnaku.com" rel="noopener noreferrer"&gt;https://minnaku.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>prisma</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
