Pages

Chapter 6 — Dynamic Content Pages | Next.js Course
Next.js Rebuild Course Chapter 6 of 10

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.

SSG
Static Generation
Pages built once at npm run build. Served as plain HTML from CDN. Fastest possible load time.
Use when: content never changes between deploys (documentation, marketing).
ISR ✓ ours
Incremental Static Regen
Built statically, but revalidated in the background after a time window. Stale-while-revalidate pattern. CDN-fast and stays fresh.
Use when: content changes occasionally — updating a lesson page should show within minutes, not hours.
SSR
Server-Side Rendering
DB query on every request. Always fresh. Slower — user waits for the DB round-trip on every page load.
Use when: content is user-specific or changes by the second (dashboards, feeds).
How you opt in: exporting 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

/[subject] app/[subject]/page.tsx
Fetches one Subject by slug. Lists its Subtopics as cards. Calls notFound() if the slug isn't in the database. Example: /japan → "Japanese" with links to Hiragana, Kanji, Vocabulary…
/[subject]/[subtopic] app/[subject]/[subtopic]/page.tsx
Fetches one Subtopic (filtered by subject slug). Lists its Pages as links. Renders breadcrumb: Home → Japanese → Kanji. Example: /japan/kanji → lists all kanji pages.
/[subject]/[subtopic]/[page] app/[subject]/[subtopic]/[page]/page.tsx
Fetches one Page with all its PageContent sections. Renders each section as a heading + HTML body. Example: /japan/kanji/stroke-order → full lesson content from TinyMCE.
/japan/kanji/[...char] app/japan/kanji/[...char]/page.tsx
Catch-all route for individual kanji characters. Takes priority over the generic [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.

generateStaticParams pattern — used in all four routes TypeScript
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

app/[subject]/page.tsx TypeScript · Server Component · ISR 1 hour
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

app/[subject]/[subtopic]/page.tsx TypeScript · Server Component · ISR 1 hour
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.

app/[subject]/[subtopic]/[page]/page.tsx TypeScript · Server Component · ISR 1 hour
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>
      )}
    </>
  )
}
One query, full page. The single 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.

app/japan/kanji/[...char]/page.tsx TypeScript · Server Component · on-demand + cached
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>
    </>
  )
}
The CJK Unicode regex /^\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.

app/api/revalidate/route.ts — secured revalidation endpoint TypeScript · API Route Handler
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:

.env.local — add revalidation secret Environment
# 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 vs parallel — know the difference TypeScript
// ❌ 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:

lib/revalidate.ts TypeScript
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.

Up next — Chapter 7: API Routes and Server Actions. We'll add the REST endpoints needed for the admin CRUD and introduce Server Actions as the modern alternative — a way to write backend logic directly inside Server Components with no separate API file needed.