The Admin Page
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.
/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.
'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
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>
</>
)
}
'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.
npm install @tinymce/tinymce-react
'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>
)
}
<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
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.
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
'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>
)
}
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
Log in and reach the admin dashboard
Visit /admin. You should see a list of subjects with their subtopic counts and Edit / Delete buttons.
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.
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.
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.
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.