The Admin Page

Chapter 9 — Admin CRUD UI | Next.js Course
Next.js Rebuild Course Chapter 9 of 10

The Admin CRUD UI

The content management loop closes here. We build every admin screen: listing and creating subjects, subtopics, and pages; a TinyMCE rich-text editor that writes directly to the database; and drag-and-drop reordering backed by a single batch-update Server Action.

🏁

Chapter milestone: You can log in, open any lesson page in the admin, edit a content section in TinyMCE, click Save, and see the live site reflect the change within seconds — without touching the filesystem or redeploying.

The admin route tree

All admin pages live inside app/(admin)/admin/ — the route group ensures the auth-checking layout wraps every page, and the /admin URL prefix keeps them clearly separated from public routes.

app/(admin)/
  layout.tsx← auth check + admin top bar (Chapter 8)
  admin/
    page.tsx← dashboard: subject list + quick stats
    subjects/
      new/page.tsx← create subject form
      [subjectId]/
        edit/page.tsx← edit subject name/slug/description
        subtopics/
          new/page.tsx
          [subtopicId]/
            edit/page.tsx
            pages/
              new/page.tsx
              [pageId]/
                edit/page.tsx← ✦ TinyMCE editor lives here
Why IDs instead of slugs in admin URLs? Public URLs use slugs (/japan/kanji/stroke-order) because they're readable. Admin URLs use numeric IDs (/admin/subjects/3/subtopics/7/pages/42/edit) because slugs can change when you rename things — an ID never changes.

actions/admin.ts — all CRUD operations in one file

All admin mutations live in a single 'use server' file. Every action checks the session first, validates with Zod, then calls Prisma. On success, revalidatePath clears the relevant ISR cache and redirect sends the admin back to the right listing page.

actions/admin.ts — subject + subtopic + page CRUD TypeScript · Server Actions · 'use server'
'use server'

import { redirect }      from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { auth }          from '@/auth'
import { prisma }        from '@/lib/prisma'
import { z }             from 'zod'

// ── Auth guard helper — call at the top of every action ───────
async function requireAdmin() {
  const session = await auth()
  if (!session || session.user.role !== 'admin') {
    throw new Error('Unauthorized')
  }
}

// ── Schemas ───────────────────────────────────────────────────
const SlugField    = z.string().regex(/^[a-z0-9-]+$/).min(1).max(100)
const SubjectInput = z.object({ name: z.string().min(1), slug: SlugField, description: z.string().optional() })
const PageInput    = z.object({ title: z.string().min(1), slug: SlugField, description: z.string().optional() })

// ══════════════════════════════════════════════════════════════
// SUBJECTS
// ══════════════════════════════════════════════════════════════

export async function createSubject(formData: FormData) {
  await requireAdmin()
  const data = SubjectInput.parse({
    name:        formData.get('name'),
    slug:        formData.get('slug'),
    description: formData.get('description') || undefined,
  })
  await prisma.subject.create({ data })
  revalidatePath('/', 'layout')
  redirect('/admin')
}

export async function updateSubject(id: number, formData: FormData) {
  await requireAdmin()
  const data = SubjectInput.parse({
    name:        formData.get('name'),
    slug:        formData.get('slug'),
    description: formData.get('description') || undefined,
  })
  const subject = await prisma.subject.update({ where: { id }, data })
  revalidatePath(`/${subject.slug}`)
  revalidatePath('/', 'layout')
  redirect('/admin')
}

export async function deleteSubject(id: number) {
  await requireAdmin()
  const subject = await prisma.subject.delete({ where: { id } })
  revalidatePath(`/${subject.slug}`)
  revalidatePath('/', 'layout')
  // No redirect — called inline with a confirm dialog, page stays
}

// ══════════════════════════════════════════════════════════════
// PAGES (most complex — includes content sections)
// ══════════════════════════════════════════════════════════════

export async function createPage(subtopicId: number, formData: FormData) {
  await requireAdmin()
  const data = PageInput.parse({
    title:       formData.get('title'),
    slug:        formData.get('slug'),
    description: formData.get('description') || undefined,
  })
  const count = await prisma.page.count({ where: { subtopicId } })
  const page  = await prisma.page.create({
    data: { ...data, subtopicId, position: count },
    include: { subtopic: { include: { subject: true } } },
  })
  revalidatePath(`/${page.subtopic.subject.slug}/${page.subtopic.slug}`)
  redirect(`/admin/subjects/${page.subtopic.subjectId}/subtopics/${subtopicId}/pages/${page.id}/edit`)
}

export async function updatePage(id: number, formData: FormData) {
  await requireAdmin()
  const data = PageInput.parse({
    title:       formData.get('title'),
    slug:        formData.get('slug'),
    description: formData.get('description') || undefined,
  })
  const page = await prisma.page.update({
    where:   { id },
    data,
    include: { subtopic: { include: { subject: true } } },
  })
  // Revalidate both the listing and the content page
  const { subject, ...sub } = page.subtopic
  revalidatePath(`/${subject.slug}/${sub.slug}/${page.slug}`)
  revalidatePath(`/${subject.slug}/${sub.slug}`)
}

// ══════════════════════════════════════════════════════════════
// PAGE CONTENT SECTIONS
// ══════════════════════════════════════════════════════════════

const ContentInput = z.object({
  heading:  z.string().min(1).max(200),
  body:     z.string().min(1),
  position: z.coerce.number().int().min(0),
})

export async function upsertContentSection(
  pageId:    number,
  sectionId: number | null,
  pagePath:  string,
  formData:  FormData
) {
  await requireAdmin()
  const data = ContentInput.parse({
    heading:  formData.get('heading'),
    body:     formData.get('body'),     // raw HTML from TinyMCE
    position: formData.get('position'),
  })

  if (sectionId) {
    // Update existing
    await prisma.pageContent.update({ where: { id: sectionId }, data })
  } else {
    // Create new — append at end
    const count = await prisma.pageContent.count({ where: { pageId } })
    await prisma.pageContent.create({ data: { ...data, pageId, position: count } })
  }

  revalidatePath(pagePath)
}

export async function deleteContentSection(id: number, pagePath: string) {
  await requireAdmin()
  await prisma.pageContent.delete({ where: { id } })
  revalidatePath(pagePath)
}

// ══════════════════════════════════════════════════════════════
// REORDER — bulk position update
// ══════════════════════════════════════════════════════════════
export async function reorderContentSections(
  sections: { id: number; position: number }[],
  pagePath: string
) {
  await requireAdmin()
  // Run all position updates in a single DB transaction
  await prisma.$transaction(
    sections.map(({ id, position }) =>
      prisma.pageContent.update({ where: { id }, data: { position } })
    )
  )
  revalidatePath(pagePath)
}
z.coerce.number() is important for form data. HTML forms always submit strings — even <input type="number">. z.number() would reject "3" with a type error. z.coerce.number() calls Number("3") first, then validates.

app/(admin)/admin/page.tsx — the dashboard

app/(admin)/admin/page.tsx TypeScript · Server Component
import { prisma }  from '@/lib/prisma'
import Link        from 'next/link'
import { deleteSubject } from '@/actions/admin'

export default async function AdminDashboard() {
  const subjects = await prisma.subject.findMany({
    orderBy: { position: 'asc' },
    include: {
      _count: { select: { subtopics: true } },
    },
  })

  return (
    <>
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-site-text">Subjects</h1>
        <Link href="/admin/subjects/new" className="badge-accent px-4 py-2 rounded text-sm">
          + New subject
        </Link>
      </div>

      <ul className="space-y-2">
        {subjects.map((subject) => (
          <li key={subject.id} className="card p-4 flex items-center justify-between gap-4">
            <div>
              <p className="font-medium text-site-text">{subject.name}</p>
              <p className="text-xs text-site-dim mt-0.5">
                /{subject.slug} · {subject._count.subtopics} subtopics
              </p>
            </div>
            <div className="flex items-center gap-2 flex-shrink-0">
              <Link
                href={`/admin/subjects/${subject.id}/subtopics`}
                className="text-xs text-site-muted hover:text-site-text px-2 py-1 rounded border border-site-border"
              >
                Subtopics
              </Link>
              <Link
                href={`/admin/subjects/${subject.id}/edit`}
                className="text-xs text-site-accent hover:text-site-text px-2 py-1 rounded border border-site-border"
              >
                Edit
              </Link>
              {/* Delete — wrapped in a form for the Server Action */}
              <DeleteButton id={subject.id} name={subject.name} />
            </div>
          </li>
        ))}
      </ul>
    </>
  )
}
components/admin/DeleteButton.tsx — confirm before deleting TypeScript · Client Component
'use client'

import { useTransition } from 'react'
import { deleteSubject } from '@/actions/admin'

export default function DeleteButton({ id, name }: { id: number; name: string }) {
  const [isPending, startTransition] = useTransition()

  function handleDelete() {
    if (!confirm(`Delete "${name}" and ALL its content? This cannot be undone.`)) return
    startTransition(async () => {
      await deleteSubject(id)
    })
  }

  return (
    <button
      onClick={handleDelete}
      disabled={isPending}
      className="text-xs text-pink-400 hover:text-pink-300 px-2 py-1
                 rounded border border-pink-400/30 disabled:opacity-50"
    >
      {isPending ? 'Deleting…' : 'Delete'}
    </button>
  )
}

// Note: useTransition lets us call the Server Action directly (no form needed)
// and shows a pending state while the delete is in progress.

TinyMCE — wiring the existing editor to the database

The original site already uses TinyMCE for content editing. We keep that — no need to change the editor. The new wrinkle is that TinyMCE is a Client Component (it runs in the browser), but our save action is a Server Action. The bridge is a hidden <input> that TinyMCE writes into on every change.

Install TinyMCE React wrapper Terminal
npm install @tinymce/tinymce-react
components/admin/ContentSectionEditor.tsx — TinyMCE + Server Action bridge TypeScript · Client Component
'use client'

import { useRef, useTransition } from 'react'
import { Editor as TinyMCEEditor } from '@tinymce/tinymce-react'
import { upsertContentSection }   from '@/actions/admin'

interface Props {
  pageId:    number
  pagePath:  string           // e.g. '/japan/kanji/stroke-order'
  sectionId: number | null   // null = creating a new section
  defaultHeading: string
  defaultBody:    string
  defaultPosition: number
}

export default function ContentSectionEditor({
  pageId, pagePath, sectionId,
  defaultHeading, defaultBody, defaultPosition,
}: Props) {
  const bodyRef  = useRef<string>(defaultBody)
  const formRef  = useRef<HTMLFormElement>(null)
  const [pending, startTransition] = useTransition()
  const [saved, setSaved] = useState(false)

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    const form = formRef.current
    if (!form) return

    // Write TinyMCE's current HTML into the hidden input before submitting
    const bodyInput = form.querySelector<HTMLInputElement>('[name="body"]')!
    bodyInput.value = bodyRef.current

    const formData = new FormData(form)

    startTransition(async () => {
      await upsertContentSection(pageId, sectionId, pagePath, formData)
      setSaved(true)
      setTimeout(() => setSaved(false), 3000)
    })
  }

  return (
    <form ref={formRef} onSubmit={handleSubmit} className="space-y-4">
      <input type="hidden" name="position" value={defaultPosition} />

      {/* Section heading */}
      <div>
        <label className="block text-sm text-site-muted mb-1">Section heading</label>
        <input
          name="heading"
          defaultValue={defaultHeading}
          className="w-full bg-site-deep border border-site-border rounded
                     px-3 py-2 text-sm text-site-text focus:outline-none focus:border-site-accent"
        />
      </div>

      {/* Hidden textarea that holds TinyMCE's HTML output */}
      <input type="hidden" name="body" defaultValue={defaultBody} />

      {/* TinyMCE editor */}
      <TinyMCEEditor
        apiKey="no-api-key"  // use self-hosted or set NEXT_PUBLIC_TINYMCE_KEY
        initialValue={defaultBody}
        onEditorChange={(content) => { bodyRef.current = content }}
        init={{
          height:  500,
          skin:    'oxide-dark',    // TinyMCE dark theme
          content_css: 'dark',
          menubar: false,
          plugins: ['lists', 'link', 'image', 'table', 'code', 'codesample'],
          toolbar: 'undo redo | blocks | bold italic | ' +
                   'bullist numlist | link image | table | codesample | code',
          codesample_languages: [
            { text: 'JavaScript', value: 'javascript' },
            { text: 'TypeScript', value: 'typescript' },
            { text: 'Python',     value: 'python'     },
            { text: 'Bash',       value: 'bash'       },
            { text: 'Japanese',   value: 'japanese'   },
          ],
        }}
      />

      <div className="flex items-center gap-4">
        <button
          type="submit"
          disabled={pending}
          className="badge-accent px-5 py-2 rounded text-sm font-semibold disabled:opacity-50"
        >
          {pending ? 'Saving…' : 'Save section'}
        </button>
        {saved && <span className="text-sm text-green-400">✓ Saved!</span>}
      </div>
    </form>
  )
}
Why a hidden <input> instead of a controlled textarea? TinyMCE manages its own DOM — it's an iframe-based editor that React can't directly control with useState. We keep TinyMCE's content in a useRef (no re-renders on every keystroke), and only write it into the form at submit time. This is significantly faster than syncing state on every change.

The full page edit screen

app/(admin)/admin/subjects/[subjectId]/subtopics/[subtopicId]/pages/[pageId]/edit/page.tsx TypeScript · Server Component
import { notFound }             from 'next/navigation'
import { prisma }               from '@/lib/prisma'
import ContentSectionEditor    from '@/components/admin/ContentSectionEditor'
import ReorderableList         from '@/components/admin/ReorderableList'
import { updatePage, deleteContentSection } from '@/actions/admin'
import Link                    from 'next/link'

interface Props { params: { subjectId: string; subtopicId: string; pageId: string } }

export default async function EditPageScreen({ params }: Props) {
  const pageId = Number(params.pageId)
  const page   = await prisma.page.findUnique({
    where:   { id: pageId },
    include: {
      pageContent: { orderBy: { position: 'asc' } },
      subtopic:    { include: { subject: true } },
    },
  })
  if (!page) notFound()

  // Build the public URL for this page (used for ISR revalidation)
  const pagePath = `/${page.subtopic.subject.slug}/${page.subtopic.slug}/${page.slug}`

  // Bind the updatePage action to this specific page ID
  const updateThisPage = updatePage.bind(null, pageId)

  return (
    <>
      <div className="flex items-center gap-3 mb-6 text-sm text-site-dim">
        <Link href="/admin">Subjects</Link> /
        <Link href={`/admin/subjects/${params.subjectId}/subtopics`}>Subtopics</Link> /
        <span className="text-site-text">{page.title}</span>
        <a href={pagePath} target="_blank" className="text-site-accent hover:underline ml-auto">
          View live ↗
        </a>
      </div>

      {/* ── Page metadata form ── */}
      <details className="card p-4 mb-8">
        <summary className="cursor-pointer text-sm font-semibold text-site-text">
          Page metadata (title, slug, description)
        </summary>
        <form action={updateThisPage} className="mt-4 space-y-3">
          <input name="title" defaultValue={page.title} className="input w-full" />
          <input name="slug"  defaultValue={page.slug}  className="input w-full font-mono" />
          <textarea name="description" defaultValue={page.description ?? ''} rows={2} className="input w-full" />
          <button type="submit" className="badge-accent px-4 py-1.5 rounded text-sm">Save metadata</button>
        </form>
      </details>

      {/* ── Content sections ── */}
      <div className="flex items-center justify-between mb-4">
        <h2 className="text-lg font-bold">Content sections</h2>
      </div>

      {/* Drag-and-drop reorder list */}
      <ReorderableList sections={page.pageContent} pagePath={pagePath} />

      {/* Existing sections — one editor each */}
      <div className="space-y-6 mt-6">
        {page.pageContent.map((section) => (
          <div key={section.id} className="card p-6">
            <div className="flex items-center justify-between mb-4">
              <p className="text-xs text-site-dim font-mono">Section #{section.position + 1}</p>
              <DeleteSectionButton id={section.id} pagePath={pagePath} />
            </div>
            <ContentSectionEditor
              pageId={page.id}
              pagePath={pagePath}
              sectionId={section.id}
              defaultHeading={section.heading}
              defaultBody={section.body}
              defaultPosition={section.position}
            />
          </div>
        ))}
      </div>

      {/* New section form */}
      <div className="card p-6 mt-6 border-dashed">
        <p className="text-sm text-site-dim mb-4">+ Add new section</p>
        <ContentSectionEditor
          pageId={page.id}
          pagePath={pagePath}
          sectionId={null}
          defaultHeading=""
          defaultBody=""
          defaultPosition={page.pageContent.length}
        />
      </div>
    </>
  )
}
action.bind(null, id) is the clean way to pre-fill a Server Action's first argument. updatePage.bind(null, pageId) returns a new function that always calls updatePage(pageId, formData) — you can pass it directly to <form action={...}> without a wrapper.

Drag-and-drop section reordering with @dnd-kit

@dnd-kit is the modern drag-and-drop library for React — it's accessible, touch-friendly, and works with Server Components because the drag state stays entirely in the Client Component. On drop, we call reorderContentSections with the new positions array.

Install @dnd-kit Terminal
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
components/admin/ReorderableList.tsx TypeScript · Client Component
'use client'

import { useState, useTransition }     from 'react'
import { DndContext, closestCenter,
         KeyboardSensor, PointerSensor,
         useSensor, useSensors,
         type DragEndEvent }           from '@dnd-kit/core'
import { SortableContext,
         sortableKeyboardCoordinates,
         verticalListSortingStrategy,
         useSortable,
         arrayMove }                  from '@dnd-kit/sortable'
import { CSS }                         from '@dnd-kit/utilities'
import { reorderContentSections }      from '@/actions/admin'
import type { PageContent }           from '@prisma/client'

// ── Individual sortable row ───────────────────────────────────
function SortableRow({ section }: { section: PageContent }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging }
    = useSortable({ id: section.id })

  return (
    <div
      ref={setNodeRef}
      style={{ transform: CSS.Transform.toString(transform), transition }}
      className={`card p-3 flex items-center gap-3 select-none
        ${isDragging ? 'opacity-50 border-site-accent' : ''}`}
    >
      {/* Drag handle */}
      <button
        type="button"
        {...attributes} {...listeners}
        className="cursor-grab active:cursor-grabbing text-site-dim hover:text-site-text p-1"
        aria-label="Drag to reorder"
      ></button>
      <span className="text-sm text-site-muted flex-1 truncate">
        {section.heading}
      </span>
      <span className="text-xs text-site-dim font-mono">#{section.position + 1}</span>
    </div>
  )
}

// ── Sortable list ─────────────────────────────────────────────
interface Props { sections: PageContent[]; pagePath: string }

export default function ReorderableList({ sections: initial, pagePath }: Props) {
  const [sections, setSections] = useState(initial)
  const [pending, startTransition] = useTransition()

  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  )

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event
    if (!over || active.id === over.id) return

    const oldIndex = sections.findIndex((s) => s.id === active.id)
    const newIndex = sections.findIndex((s) => s.id === over.id)
    const reordered = arrayMove(sections, oldIndex, newIndex).map((s, i) => ({
      ...s, position: i,
    }))

    setSections(reordered)   // optimistic UI update

    startTransition(async () => {
      await reorderContentSections(
        reordered.map(({ id, position }) => ({ id, position })),
        pagePath,
      )
    })
  }

  return (
    <div className=`space-y-2 ${pending ? 'opacity-60' : ''}`>
      <p className="text-xs text-site-dim mb-2">Drag to reorder sections</p>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={sections.map((s) => s.id)}
          strategy={verticalListSortingStrategy}
        >
          {sections.map((section) => (
            <SortableRow key={section.id} section={section} />
          ))}
        </SortableContext>
      </DndContext>
    </div>
  )
}
Optimistic update pattern: setSections(reordered) runs immediately, so the list visually snaps to the new order with zero delay. startTransition then sends the Server Action in the background. If the action fails, you'd call setSections(initial) to roll back — for a personal admin tool, a simple page refresh is also acceptable.

The full content editing loop

1

Log in and reach the admin dashboard

Visit /admin. You should see a list of subjects with their subtopic counts and Edit / Delete buttons.

2

Navigate to a page edit screen

Subjects → Subtopics → Pages → Edit. The full URL chain resolves through four levels of admin routing. The "View live ↗" link in the top-right opens the public page in a new tab.

3

Edit a section in TinyMCE and save

Change text in the editor. Click "Save section". The pending state shows "Saving…". On success "✓ Saved!" appears. The live tab auto-refreshes within the ISR window.

4

Drag sections to reorder

Grab the ⠿ handle on any section row and drag it above or below another. Release — the order snaps immediately (optimistic update), and the DB transaction runs in the background. Reload the live page to confirm the new order.

5

Delete a section with confirmation

Click Delete on a section. The browser confirm dialog asks "Delete…? This cannot be undone." Confirming removes it from the DB and the ISR cache. The section disappears from the editor list without a full page reload.

Up next — Chapter 10: Deployment & Production. We connect to the production MySQL database, set all environment variables on the server, run the Prisma migration, configure the Next.js build output, and deploy. We also cover PM2 for process management and Nginx as a reverse proxy.