Admin CRUD Interface

Website Rebuild with Next.js

Chapter 10 · Admin CRUD Interface

This chapter builds the real admin interface by hand — Next.js ships no automatic scaffolding of any kind — and finally pays off Chapter 2's own deferred cost: recalculating an entire subtree's own fullPath when a page is moved.

No Free Admin Panel

Next.jsDjangoLaravelRailsAstroExpress
Free admin/scaffold?No — hand-builtYes — admin.site.register(), liveNo by default (Nova/Filament optional)Partial — rails generate scaffold, one-time codegenNo — hand-builtNo — hand-built

Django's own admin app is a live, runtime-introspected interface generated from the model definition itself — genuinely different in kind from Rails' scaffold generator, which produces real, editable source files once and then stops helping. Next.js has neither. Every line of this chapter's own interface is written directly.

The Parent Picker: A Searchable Flat List

The design decision every sibling course explicitly credits to this chapter
Choosing a new parent for a page — at arbitrary depth, from potentially hundreds of candidates — could be a nested tree widget, expandable level by level, or a plain flat list of every other page's own fullPath, filterable as the admin types. This chapter uses the flat list: at this site's realistic page count, typing a few letters to jump straight to the right page beats clicking through several levels of a tree, and a flat list is genuinely simpler to build in the first place. Laravel Rebuild's, Astro Rebuild's, and Rails Rebuild's own Chapter 10s all reuse this identical reasoning, citing it directly as "established since the Next.js rebuild's own Chapter 10."
// components/ParentPicker.tsx 'use client'; import { useState } from 'react'; import type { Page } from '@prisma/client'; export default function ParentPicker({ pages, onSelect }: { pages: Page[]; onSelect: (id: number) => void }) { const [query, setQuery] = useState(''); const filtered = pages.filter((p) => p.fullPath.toLowerCase().includes(query.toLowerCase()) ); return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search by path…" /> <ul> {filtered.map((p) => ( <li key={p.id} onClick={() => onSelect(p.id)}>{p.fullPath}</li> ))} </ul> </div> ); }

Reparenting: Paying Off Chapter 2's Own Deferred Cost

// lib/movePage.ts import { prisma } from './prisma'; type DescendantRow = { id: number; parentId: number; slug: string; depth: number }; export async function movePage(pageId: number, newParentId: number | null) { const page = await prisma.page.findUniqueOrThrow({ where: { id: pageId } }); const newParent = newParentId ? await prisma.page.findUniqueOrThrow({ where: { id: newParentId } }) : null; const newFullPath = newParent ? `${newParent.fullPath}/${page.slug}` : page.slug; // One query, via a recursive CTE, finds every affected descendant — // ordered by depth, so each row's own parent is always processed first const descendants = await prisma.$queryRaw<DescendantRow[]>` WITH RECURSIVE descendants AS ( SELECT id, parentId, slug, 0 AS depth FROM Page WHERE id = ${pageId} UNION ALL SELECT p.id, p.parentId, p.slug, d.depth + 1 FROM Page p INNER JOIN descendants d ON p.parentId = d.id ) SELECT * FROM descendants WHERE depth > 0 ORDER BY depth ASC `; await prisma.page.update({ where: { id: pageId }, data: { parentId: newParentId, fullPath: newFullPath } }); const recalculated = new Map<number, string>([[pageId, newFullPath]]); for (const row of descendants) { const parentPath = recalculated.get(row.parentId)!; const updatedPath = `${parentPath}/${row.slug}`; recalculated.set(row.id, updatedPath); await prisma.page.update({ where: { id: row.id }, data: { fullPath: updatedPath } }); } }
A genuine improvement — honestly, only a partial one
Discovering every affected descendant takes exactly one query, via $queryRaw's recursive CTE — a real improvement over walking the tree level by level in application code, since Chapter 6's own include is fixed-depth and couldn't express "every descendant, however deep" at all. The write step stays honestly sequential regardless: each descendant's own new fullPath depends on its own parent's already-updated path, so the loop above genuinely can't be parallelized. The improvement is real, and it's real specifically at the discovery step, not the whole operation.
Why an escape hatch, not the query builder
Prisma's own query builder — include, where, and the rest — has no way to express a recursive query at all; recursion isn't part of its vocabulary. $queryRaw is Prisma's own documented escape hatch for exactly this situation: real SQL, still returned as typed rows, for the specific cases the query builder genuinely can't reach.

Delete Refusal: From Raw Exception to a Friendly Message

// app/actions.ts — new action import { Prisma } from '@prisma/client'; export async function deletePage(pageId: number) { const session = await auth(); if (!session) throw new Error('Unauthorized'); try { await prisma.page.delete({ where: { id: pageId } }); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003' ) { throw new Error('This page still has child pages — move or delete them first.'); } throw error; } }
Same layer as Laravel, Astro, and Express — the shared try/catch camp
P2003 is Prisma's own documented error code for a foreign key constraint violation — the raw database refusal from Chapter 2's own onDelete: Restrict, surfacing here as a real exception that has to be caught, not a clean return value. This matches Laravel's try/catch around a raw QueryException and Astro's own equivalent, since all three sit on a database-level constraint. Django's PROTECT and Rails' dependent: :restrict_with_error are both application-level instead — Django still raises an exception to catch, but Rails' own destroy simply returns false with no exception-catching needed at all, a genuine ergonomic advantage specific to Rails' own API design, not a universal trait of every application-level approach.

Hands-On Exercises

Exercise 1

Build ParentPicker and confirm typing a partial path (e.g. "prog") correctly filters the list down to only matching pages, client-side, with no new server request per keystroke.

📄 View solution
Exercise 2

Move a page with at least two real levels of descendants to a new parent, and confirm every descendant's own fullPath is correctly recalculated — not just the moved page itself.

📄 View solution
Exercise 3

Attempt to delete a page that still has at least one child, and confirm the friendly error message appears rather than an unhandled Prisma exception crashing the request.

📄 View solution

Chapter 10 Quick Reference

  • No free admin panel — hand-built, matching Laravel/Astro/Express's own shared finding
  • Searchable flat-list parent picker — the design decision every sibling course credits to this chapter
  • movePage() / recursive CTE via $queryRaw — one query discovers every descendant; the write step stays honestly sequential
  • P2003 — Prisma's own foreign key error code, caught and converted into a real UI message
  • Next chapter: Deployment