Pages
Dynamic Content Pages
Wire every dynamic route from Chapter 2 to the Prisma queries from Chapter 5. The subject page, subtopic listing, full content pages, and the kanji catch-all all get real data — plus static generation so pages load instantly from cache.
Chapter milestone: Every URL on osztromok.com serves real content.
/japan/kanji/stroke-order
renders TinyMCE HTML from the database.
/japan/kanji/水
renders the kanji detail page. Unknown slugs return a proper 404.
When is a page rendered? Three strategies
Next.js decides when to run your page's data-fetching code. The choice affects performance, freshness, and infrastructure cost. For osztromok.com — a content site where pages rarely change — ISR is ideal.
npm run build. Served as plain HTML from CDN. Fastest possible load time.export const revalidate = 3600
from a page makes it ISR with a 1-hour revalidation window. No revalidate
export = static (SSG). export const dynamic = 'force-dynamic' = SSR.
The default for a page that doesn't call cookies(), headers(),
or searchParams is static.
The four dynamic routes we're building
notFound() if the slug isn't in the database.
Example: /japan → "Japanese" with links to Hiragana, Kanji, Vocabulary…
/japan/kanji → lists all kanji pages.
/japan/kanji/stroke-order → full lesson content from TinyMCE.
[subtopic]/[page] route because the static japan/kanji prefix is more specific.
Example: /japan/kanji/水 → kanji detail card.
generateStaticParams() — pre-build all routes
For ISR/SSG to work with dynamic segments, Next.js needs to know which slugs exist
at build time. generateStaticParams() returns the list — Next.js pre-renders
a page for each combination. Unknown slugs are either blocked (404) or rendered
on demand depending on your dynamicParams setting.
import { prisma } from '@/lib/prisma'
// ── Subject page (/[subject]) ─────────────────────────────────
export async function generateStaticParams() {
const subjects = await prisma.subject.findMany({
select: { slug: true },
})
return subjects.map((s) => ({ subject: s.slug }))
// returns: [{ subject: 'japan' }, { subject: 'french' }, …]
}
// ── Content page (/[subject]/[subtopic]/[page]) ────────────────
export async function generateStaticParams() {
const pages = await prisma.page.findMany({
select: {
slug: true,
subtopic: {
select: {
slug: true,
subject: { select: { slug: true } },
},
},
},
})
return pages.map((p) => ({
subject: p.subtopic.subject.slug,
subtopic: p.subtopic.slug,
page: p.slug,
}))
}
// ── Kanji catch-all (/japan/kanji/[...char]) ───────────────────
// We DON'T pre-generate kanji pages — there are thousands of characters.
// Return empty array + dynamicParams: true → render on demand, cache result.
export async function generateStaticParams() {
return [] // generate all on first request, then cache
}
export const dynamicParams = true // allow slugs NOT in generateStaticParams
dynamicParams = false (the default when you provide
generateStaticParams) makes Next.js return a 404 for any slug
not in the list. Use this for content pages — if someone types a made-up slug,
they should get a 404, not a blank page. Set it to true only for
routes like the kanji page where any valid Unicode character is a valid route.
app/[subject]/page.tsx — the subject landing page
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import Breadcrumb from '@/components/Breadcrumb'
export const revalidate = 3600 // ISR — rebuild page at most once per hour
export const dynamicParams = false // 404 for slugs not in generateStaticParams
interface Props { params: { subject: string } }
export async function generateStaticParams() {
const subjects = await prisma.subject.findMany({ select: { slug: true } })
return subjects.map((s) => ({ subject: s.slug }))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const subject = await prisma.subject.findUnique({ where: { slug: params.subject } })
return { title: subject?.name ?? params.subject }
}
export default async function SubjectPage({ params }: Props) {
const subject = await prisma.subject.findUnique({
where: { slug: params.subject },
include: { subtopics: { orderBy: { position: 'asc' } } },
})
if (!subject) notFound()
return (
<>
<Breadcrumb crumbs={[{ label: subject.name, href: `/${subject.slug}` }]} />
<h1 className="text-3xl font-bold text-site-text mb-2">
{subject.name}
</h1>
{subject.description && (
<p className="text-site-muted mb-8 leading-relaxed">
{subject.description}
</p>
)}
<p className="section-label mb-4">Topics</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{subject.subtopics.map((sub) => (
<Link
key={sub.id}
href={`/${subject.slug}/${sub.slug}`}
className="card p-4 hover:border-site-accent transition-colors group"
>
<h2 className="font-semibold text-site-text group-hover:text-site-accent transition-colors">
{sub.name}
</h2>
{sub.description && (
<p className="text-sm text-site-muted mt-1">{sub.description}</p>
)}
</Link>
))}
</div>
</>
)
}
app/[subject]/[subtopic]/page.tsx — the page listing
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import Breadcrumb from '@/components/Breadcrumb'
export const revalidate = 3600
export const dynamicParams = false
interface Props { params: { subject: string; subtopic: string } }
export async function generateStaticParams() {
const subtopics = await prisma.subtopic.findMany({
select: { slug: true, subject: { select: { slug: true } } },
})
return subtopics.map((s) => ({
subject: s.subject.slug,
subtopic: s.slug,
}))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const sub = await prisma.subtopic.findFirst({
where: { slug: params.subtopic, subject: { slug: params.subject } },
include: { subject: true },
})
return { title: sub ? `${sub.name} — ${sub.subject.name}` : params.subtopic }
}
export default async function SubtopicPage({ params }: Props) {
const subtopic = await prisma.subtopic.findFirst({
where: { slug: params.subtopic, subject: { slug: params.subject } },
include: {
subject: true,
pages: { orderBy: { position: 'asc' } },
},
})
if (!subtopic) notFound()
return (
<>
<Breadcrumb crumbs={[
{ label: subtopic.subject.name, href: `/${params.subject}` },
{ label: subtopic.name, href: `/${params.subject}/${params.subtopic}` },
]} />
<h1 className="text-3xl font-bold text-site-text mb-8">
{subtopic.name}
</h1>
<p className="section-label mb-4">Pages</p>
<ul className="divide-y divide-site-border card overflow-hidden">
{subtopic.pages.map((page) => (
<li key={page.id}>
<Link
href={`/${params.subject}/${params.subtopic}/${page.slug}`}
className="flex items-center justify-between px-4 py-3
hover:bg-site-card transition-colors group"
>
<span className="text-sm text-site-muted group-hover:text-site-text transition-colors">
{page.title}
</span>
<span className="text-site-dim text-xs">→</span>
</Link>
</li>
))}
</ul>
</>
)
}
app/[subject]/[subtopic]/[page]/page.tsx — full content
This is the most important route — it renders the actual lesson content. It fetches the page record, all its content sections, and the parent chain for breadcrumbs in a single Prisma query.
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { prisma } from '@/lib/prisma'
import Breadcrumb from '@/components/Breadcrumb'
import ContentSection from '@/components/ContentSection'
export const revalidate = 3600
export const dynamicParams = false
interface Props {
params: { subject: string; subtopic: string; page: string }
}
export async function generateStaticParams() {
const pages = await prisma.page.findMany({
select: {
slug: true,
subtopic: { select: { slug: true, subject: { select: { slug: true } } } },
},
})
return pages.map((p) => ({
subject: p.subtopic.subject.slug,
subtopic: p.subtopic.slug,
page: p.slug,
}))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const page = await prisma.page.findFirst({
where: {
slug: params.page,
subtopic: { slug: params.subtopic, subject: { slug: params.subject } },
},
include: { subtopic: { include: { subject: true } } },
})
if (!page) return { title: 'Not found' }
return {
title: page.title,
description: page.description ?? undefined,
}
}
export default async function ContentPage({ params }: Props) {
const page = await prisma.page.findFirst({
where: {
slug: params.page,
subtopic: {
slug: params.subtopic,
subject: { slug: params.subject },
},
},
include: {
pageContent: { orderBy: { position: 'asc' } },
subtopic: { include: { subject: true } },
},
})
if (!page) notFound()
return (
<>
<Breadcrumb crumbs={[
{ label: page.subtopic.subject.name, href: `/${params.subject}` },
{ label: page.subtopic.name, href: `/${params.subject}/${params.subtopic}` },
{ label: page.title, href: `/${params.subject}/${params.subtopic}/${params.page}` },
]} />
<h1 className="text-3xl font-bold text-site-text mb-2">
{page.title}
</h1>
{page.description && (
<p className="text-site-muted mb-8 leading-relaxed">
{page.description}
</p>
)}
{/* Render each content section from the DB */}
{page.pageContent.map((section) => (
<ContentSection
key={section.id}
heading={section.heading}
body={section.body}
/>
))}
{page.pageContent.length === 0 && (
<p className="text-site-dim italic">No content yet.</p>
)}
</>
)
}
prisma.page.findFirst()
call with nested include fetches the page title, description, all content
sections, and the full parent chain (subtopic → subject) needed for breadcrumbs.
Prisma compiles this to a single SQL query with JOINs — not N+1 separate queries.
app/japan/kanji/[...char]/page.tsx — the kanji catch-all
This route handles individual kanji characters like /japan/kanji/水.
Because the static prefix japan/kanji/ is more specific than the dynamic
[subject]/[subtopic]/, Next.js always routes kanji URLs here first — exactly
how the FastAPI version worked, but with zero extra configuration.
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
// Don't pre-generate — too many possible kanji characters.
// Render on first request, cache indefinitely (revalidate manually via admin).
export async function generateStaticParams() { return [] }
export const dynamicParams = true // allow any character, not just pre-listed ones
export const revalidate = false // cache forever once rendered (kanji data is stable)
interface Props { params: { char: string[] } }
// ── Kanji data lookup ─────────────────────────────────────────
// The kanji resource files live at /resources/japanese/kanji/.json
// This matches the pattern from the FastAPI course.
async function getKanjiData(character: string) {
try {
// Fetch from our own API route (Chapter 7 adds this)
// or directly from a JSON file in /public/resources/japanese/kanji/
const res = await fetch(
`${process.env.NEXT_PUBLIC_SITE_URL}/resources/japanese/kanji/${encodeURIComponent(character)}.json`,
{ next: { revalidate: false } } // cache indefinitely
)
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const character = decodeURIComponent(params.char[0] ?? '')
return { title: `${character} — Kanji` }
}
export default async function KanjiPage({ params }: Props) {
const character = decodeURIComponent(params.char[0] ?? '')
// Validate: must be a single CJK character
const isCJK = /^\p{Script=Han}$/u.test(character)
if (!character || !isCJK) notFound()
const data = await getKanjiData(character)
// data may be null if no JSON file exists — page still renders with just the character
return (
<>
{/* Breadcrumb */}
<nav className="text-sm text-site-dim mb-6">
<a href="/" className="hover:text-site-text">Home</a>{' / '}
<a href="/japan" className="hover:text-site-text">Japanese</a>{' / '}
<a href="/japan/kanji" className="hover:text-site-text">Kanji</a>{' / '}
<span className="text-site-muted">{character}</span>
</nav>
{/* Hero character display */}
<div className="flex items-start gap-8 mb-10 flex-wrap">
<div className="card p-6 text-center min-w-[120px]">
<span className="text-7xl leading-none text-site-text block mb-2">
{character}
</span>
<span className="text-xs text-site-dim">kanji</span>
</div>
{data && (
<div className="flex-1 min-w-[200px]">
<p className="section-label mb-3">Readings</p>
{data.kunyomi && (
<p className="text-sm mb-1">
<span className="text-site-dim">Kun'yomi:</span>{' '}
<span className="text-site-text">{data.kunyomi}</span>
</p>
)}
{data.onyomi && (
<p className="text-sm mb-1">
<span className="text-site-dim">On'yomi:</span>{' '}
<span className="text-site-text">{data.onyomi}</span>
</p>
)}
{data.meaning && (
<p className="text-sm text-site-muted mt-2">{data.meaning}</p>
)}
</div>
)}
</div>
</>
)
}
/^\p{Script=Han}$/u validates
that the URL segment is actually a Chinese/Japanese/Korean character, not a random
string someone typed into the URL. The u flag enables Unicode property
escapes — this is standard ES2018+ and works in Node.js without any libraries.
On-demand revalidation — refreshing pages after edits
With ISR, a cached page serves for up to 1 hour before the next visitor triggers a background rebuild. For an admin who just edited a lesson page and wants to see the change immediately, 1 hour is too long. Next.js has a solution: on-demand revalidation.
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
// Check the secret token to prevent public abuse
const token = req.headers.get('x-revalidate-token')
if (token !== process.env.REVALIDATE_TOKEN) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { path } = await req.json() // e.g. { path: '/japan/kanji/stroke-order' }
if (path) {
revalidatePath(path) // invalidate that specific URL
return NextResponse.json({ revalidated: path })
}
// Nuke everything (admin saved a subject name that affects nav etc.)
revalidatePath('/', 'layout') // invalidates every page sharing this layout
return NextResponse.json({ revalidated: 'all' })
}
// Call this from the admin CRUD actions (Chapter 9) after saving a page:
// await fetch('/api/revalidate', {
// method: 'POST',
// headers: { 'x-revalidate-token': process.env.REVALIDATE_TOKEN! },
// body: JSON.stringify({ path: `/japan/kanji/stroke-order` }),
// })
Add REVALIDATE_TOKEN to .env.local:
# Generate a random token:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
REVALIDATE_TOKEN="your-random-hex-string-here"
Parallel fetching — avoiding waterfalls
A common mistake is chaining await calls that could run simultaneously.
Each one waits for the previous to finish — a waterfall. Use
Promise.all() when queries are independent.
// ❌ Waterfall — each query waits for the previous: ~300ms total
const subject = await prisma.subject.findUnique({ where: { slug } }) // 100ms
const count = await prisma.page.count({ where: { subtopic: { subjectId: subject?.id } } }) // 100ms
const navItems = await prisma.subject.findMany({ select: { slug: true, name: true } }) // 100ms
// ✓ Parallel — all fire at once, wait for the slowest: ~100ms total
const [subject, count, navItems] = await Promise.all([
prisma.subject.findUnique({ where: { slug } }),
prisma.page.count({ where: { ... } }),
prisma.subject.findMany({ select: { slug: true, name: true } }),
])
// ⚠️ Only use Promise.all when queries are independent.
// If query B needs results from query A, you must await A first.
const subject = await prisma.subject.findUnique({ where: { slug } }) // must come first
if (!subject) notFound()
const subtopics = await prisma.subtopic.findMany({ // depends on subject.id
where: { subjectId: subject.id },
})
lib/revalidate.ts — a shared helper for admin actions
The admin CRUD (Chapter 9) needs to revalidate pages when content changes. Extract this into a shared helper so every Server Action can call it:
import { revalidatePath } from 'next/cache'
// Call after saving any page content
export function revalidateContentPage(
subjectSlug: string,
subtopicSlug: string,
pageSlug: string
) {
revalidatePath(`/${subjectSlug}/${subtopicSlug}/${pageSlug}`)
revalidatePath(`/${subjectSlug}/${subtopicSlug}`) // listing page
}
// Call after adding/renaming a subject (affects nav + home page)
export function revalidateSubject(subjectSlug: string) {
revalidatePath('/') // home page subject grid
revalidatePath('/', 'layout') // nav bar (shared layout)
revalidatePath(`/${subjectSlug}`)
}
// Nuclear option — clears everything (use sparingly)
export function revalidateAll() {
revalidatePath('/', 'layout')
}
Test every route end-to-end
/japan shows the subject landing page
Subtopic cards appear, ordered by position. Hovering a card shows the cyan border. Clicking navigates to the subtopic listing.
/japan/kanji shows the page listing
All pages under the Kanji subtopic appear as a list. Breadcrumb shows "Home / Japanese / Kanji". Clicking a page navigates to the content.
/japan/kanji/stroke-order shows real lesson content
Each PageContent section renders — heading as an <h2>,
body HTML from TinyMCE inside the prose-site styles. Tables, lists, code
blocks all look correct.
/japan/kanji/水 shows the kanji detail page
The large character renders. The route does not hit
app/[subject]/[subtopic]/[page]/page.tsx — confirm in the terminal
that the SQL log shows no Page table query, only the kanji data fetch.
Unknown slugs return 404
/japan/made-up-slug shows your custom not-found.tsx.
/notasubject also shows 404 (because dynamicParams = false
blocks slugs not in generateStaticParams).
Production build succeeds
Run npm run build — all pages pre-render successfully.
The build output shows which pages are Static (⚪), ISR (⏱), or Dynamic (λ).
Content pages should all show ISR.