Skip to content

Worked review 1: a blog with comments and an admin publish button

The prompt was: “Add a blog post page with comments. Logged-in admins get a Publish button that flips a draft post to published.” The result below is the kind of first draft an AI assistant hands back — it compiles, next build finishes with zero errors, and every piece looks like something you have already seen in this course. Read it the way the reading order in this module’s index recommends: boundaries first, then the data flow, then auth, then the error path. It has 6 real bugs, none of which next build catches.

// lib/db.ts — as generated
type Post = { id: string; slug: string; title: string; body: string; published: boolean }
type Comment = { id: string; postId: string; text: string }
const posts = new Map<string, Post>([
['hello-world', { id: 'p1', slug: 'hello-world', title: 'Hello, world', body: 'First post body.', published: false }],
])
const comments: Comment[] = []
export const db = {
post: {
findBySlug: async (slug: string) => posts.get(slug) ?? null,
publish: async (id: string) => {
for (const p of posts.values()) if (p.id === id) p.published = true
},
},
comment: {
listForPost: async (postId: string) => comments.filter((c) => c.postId === postId),
create: async (postId: string, text: string) => {
const c = { id: `c${comments.length + 1}`, postId, text }
comments.push(c)
return c
},
},
}
// app/lib/dal.ts — as generated
import 'server-only'
import { cache } from 'react'
import { redirect } from 'next/navigation'
import { getSession } from '@/app/lib/session'
export const verifySession = cache(async () => {
const session = await getSession()
if (!session) redirect('/login')
return session
})
// app/blog/[slug]/actions.ts — as generated
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function publishPost(id: string, slug: string) {
await db.post.publish(id)
revalidatePath(`/blog/${slug}`)
try {
redirect('/blog')
} catch (err) {
console.error('publishPost: caught instead of redirecting:', err)
}
}
export async function addComment(slug: string, formData: FormData) {
const text = String(formData.get('text') ?? '')
const post = await db.post.findBySlug(slug)
if (!post) return
await db.comment.create(post.id, text)
revalidatePath(`/blog/${slug}`)
}
// app/blog/[slug]/admin-panel.tsx — as generated
'use client'
import { verifySession } from '@/app/lib/dal'
import { publishPost } from './actions'
export function AdminPanel({ id, slug }: { id: string; slug: string }) {
async function checkAndPublish() {
const session = await verifySession()
if (session) await publishPost(id, slug)
}
return (
<form action={checkAndPublish}>
<button type="submit">Publish</button>
</form>
)
}
// app/blog/[slug]/comment-list.tsx — as generated
type Comment = { id: string; text: string }
export function CommentList({
comments,
onDelete,
}: {
comments: Comment[]
onDelete: (id: string) => void
}) {
return (
<ul>
{comments.map((c) => (
<li key={c.id}>
{c.text} <button onClick={() => onDelete(c.id)}>Delete</button>
</li>
))}
</ul>
)
}
// app/blog/[slug]/comment-form.tsx — as generated
'use client'
import { useFormStatus } from 'react-dom'
import { addComment } from './actions'
export function CommentForm({ slug }: { slug: string }) {
const { pending } = useFormStatus()
const action = addComment.bind(null, slug)
return (
<form action={action}>
<input name="text" />
<button disabled={pending} type="submit">{pending ? 'Submitting...' : 'Add comment'}</button>
</form>
)
}
// app/blog/[slug]/page.tsx — as generated
import { db } from '@/lib/db'
import { AdminPanel } from './admin-panel'
import { CommentList } from './comment-list'
import { CommentForm } from './comment-form'
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await db.post.findBySlug(slug)
if (!post) return <p>Not found</p>
const comments = await db.comment.listForPost(post.id)
return (
<main>
<h1>{post.title}</h1>
<p>{post.body}</p>
<p>Published: {String(post.published)}</p>
<AdminPanel id={post.id} slug={post.slug} />
<CommentList comments={comments} onDelete={(id) => console.log('deleted', id)} />
<CommentForm slug={slug} />
</main>
)
}

Boundaries: a Client Component importing a server-only module

Section titled “Boundaries: a Client Component importing a server-only module”

admin-panel.tsx starts with 'use client', then imports verifySession from app/lib/dal.ts — a file that itself starts with import 'server-only'. The whole point of verifySession was to be the one place that authorizes a caller; instead it just got pulled into the browser bundle.

🐛 Spot the bug

'use client'
import { verifySession } from '@/app/lib/dal'
import { publishPost } from './actions'
export function AdminPanel({ id, slug }: { id: string; slug: string }) {
async function checkAndPublish() {
const session = await verifySession()
if (session) await publishPost(id, slug)
}
return (
<form action={checkAndPublish}>
<button type="submit">Publish</button>
</form>
)
}
Show the answer

'use client' marks this whole module as a Client Component, and everything it imports has to be safe to ship to the browser. app/lib/dal.ts starts with import 'server-only' specifically to make that impossible — and Turbopack catches it at build time, not at runtime:

Error: Turbopack build failed with 2 errors:
./app/lib/dal.ts:1:1
Error: You're importing a module that depends on "server-only" into a React Client Component module. This API is only available in Server Components but one of its parents is marked with "use client", so this module is also a Client Component.
Learn more: https://nextjs.org/docs/app/building-your-application/rendering
./app/lib/dal.ts:1:1
Error: 'server-only' cannot be imported from a Client Component module
It should only be used from a Server Component.

This is a build error, not a warning — the project cannot ship until it’s fixed. The fix moves the authorization check to where it belongs: inside the publishPost Server Action itself (see the caching/auth review below), and AdminPanel goes back to being a plain client form with no import of the DAL at all.

Boundaries: a non-serializable prop from a Server Component

Section titled “Boundaries: a non-serializable prop from a Server Component”

page.tsx is a Server Component — it has no 'use client' at the top, so it renders on the server. It hands CommentList, a Client Component, an inline arrow function as the onDelete prop.

🐛 Spot the bug

import { db } from '@/lib/db'
import { CommentList } from './comment-list'
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await db.post.findBySlug(slug)
if (!post) return <p>Not found</p>
const comments = await db.comment.listForPost(post.id)
return (
<CommentList comments={comments} onDelete={(id) => console.log('deleted', id)} />
)
}
Show the answer

Props crossing from a Server Component into a Client Component have to be serializable — Next.js has to be able to turn them into the RSC payload sent to the browser, and a closure has no serializable form. Building this exact page fails prerendering, not at the import line but where the value is actually used:

Error: Event handlers cannot be passed to Client Component props.
{onDelete: function onDelete}
^^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

The fix isn’t to convert page.tsx itself into a Client Component (that would push the whole page’s data fetching into the browser) — it’s to pass a Server Action reference instead of a closure. An exported 'use server' function is specially recognized and serialized across the boundary; a plain inline arrow function is not.

Boundaries: useFormStatus in the same component as the <form>

Section titled “Boundaries: useFormStatus in the same component as the <form>”

CommentForm calls useFormStatus() directly, in the same function component that renders <form>.

🐛 Spot the bug

'use client'
import { useFormStatus } from 'react-dom'
import { addComment } from './actions'
export function CommentForm({ slug }: { slug: string }) {
const { pending } = useFormStatus()
const action = addComment.bind(null, slug)
return (
<form action={action}>
<input name="text" />
<button disabled={pending} type="submit">{pending ? 'Submitting...' : 'Add comment'}</button>
</form>
)
}
Show the answer

useFormStatus reports the pending state of the nearest ancestor <form> in the render tree — and the component that renders <form> is not its own descendant. This compiles and runs with zero errors or warnings; it just never works. Filling the text field and clicking submit, then reading the button’s DOM state 50ms later, in a real browser against this exact code:

text: "Add comment"
disabled: false

The comment is still being written (this demo’s addComment takes 3 seconds), but the button never shows it. The fix is to move useFormStatus into a separate child component rendered inside the <form>, exactly as the forms and progressive enhancement lesson first showed.

Data flow: revalidateTag — not used here, but the one-argument form is a type error

Section titled “Data flow: revalidateTag — not used here, but the one-argument form is a type error”

This feature only calls revalidatePath, which takes one argument and is fine. The AI-generated draft of this same feature in an earlier pass used tag-based invalidation instead, and got the call wrong the way revalidateTag almost always gets written from memory:

🐛 Spot the bug

import { revalidateTag } from 'next/cache'
export async function publishPost(id: string) {
await db.post.publish(id)
revalidateTag('posts')
}
Show the answer

In Next.js 16, revalidateTag’s second argument — a cache-life profile — is required. The one-argument form isn’t a runtime warning, it’s a compile error:

error TS2554: Expected 2 arguments, but got 1.

tsc --noEmit catches this one before the code ever reaches next build. The fix is revalidateTag('posts', 'max') — or, since this action already has the post’s own path in hand, revalidatePath as shown in the generated code above, which needs no profile argument at all.

Auth: publishPost never calls verifySession

Section titled “Auth: publishPost never calls verifySession”

Now the action that actually runs. AdminPanel’s bug hid a second, more serious one: once verifySession is moved out of the Client Component, where does it go?

🐛 Spot the bug

'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function publishPost(id: string, slug: string) {
await db.post.publish(id)
revalidatePath(`/blog/${slug}`)
try {
redirect('/blog')
} catch (err) {
console.error('publishPost: caught instead of redirecting:', err)
}
}
Show the answer

publishPost is a Server Action — a public POST endpoint, reachable by its action ID from anywhere, exactly as the Server Actions lesson establishes. This action never calls verifySession() or checks a role at all; it just publishes. Built and requested for real, from a browser tab with zero cookies set — no session, no login, nothing — clicking the rendered Publish button flips the post from draft to published:

before clicking Publish (anonymous visitor, no session cookie): Published: false
after clicking Publish (same anonymous visitor): Published: true

The Publish button being rendered at all here has nothing to do with it — even if page.tsx only rendered AdminPanel for logged-in admins, the action itself has no idea who called it. Render-time gating decides what the UI shows; it says nothing about what the server accepts.

Errors and redirects: redirect() swallowed by try/catch

Section titled “Errors and redirects: redirect() swallowed by try/catch”

The same publishPost action has a second, independent bug once the first one is fixed: the redirect back to the blog list after a successful publish never fires.

🐛 Spot the bug

try {
redirect('/blog')
} catch (err) {
console.error('publishPost: caught instead of redirecting:', err)
}
Show the answer

redirect() works by throwing a control-flow error that Next.js catches further up the tree. This try/catch has no filter — it catches that throw exactly like any other error, logs it, and swallows it. Built, started for real, and clicked through a browser: the page URL never changes away from /blog/hello-world, and the server’s own log shows exactly what got caught:

publishPost: caught instead of redirecting: Error: NEXT_REDIRECT
at i (.next/server/chunks/ssr/_0mwdxwe._.js:149:34576)
at j (.next/server/chunks/ssr/_0mwdxwe._.js:149:34754)
at f (.next/server/chunks/ssr/_0mwdxwe._.js:150:463) {
digest: 'NEXT_REDIRECT;push;/blog;307;'
}

The digest string is Next.js’s own encoding of the destination (/blog), the navigation type (push, the default when redirect() is called from a Server Action bound to a form), and the status (307) — all computed, all then discarded by this catch block. The fix, exactly as the mutations and revalidation lesson proves for notFound(), is to keep redirect() outside any try/catch that doesn’t explicitly rethrow it.

lib/db.ts
type Post = { id: string; slug: string; title: string; body: string; published: boolean }
type Comment = { id: string; postId: string; text: string }
const posts = new Map<string, Post>([
['hello-world', { id: 'p1', slug: 'hello-world', title: 'Hello, world', body: 'First post body.', published: false }],
])
const comments: Comment[] = []
export const db = {
post: {
findBySlug: async (slug: string) => posts.get(slug) ?? null,
publish: async (id: string) => {
for (const p of posts.values()) if (p.id === id) p.published = true
},
},
comment: {
listForPost: async (postId: string) => comments.filter((c) => c.postId === postId),
create: async (postId: string, text: string) => {
const c = { id: `c${comments.length + 1}`, postId, text }
comments.push(c)
return c
},
remove: async (id: string) => {
const i = comments.findIndex((c) => c.id === id)
if (i !== -1) comments.splice(i, 1)
},
},
}
app/lib/session.ts
import 'server-only'
import { cookies } from 'next/headers'
export async function getSession() {
const role = (await cookies()).get('session')?.value
return role ? { role } : null
}
app/lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { redirect } from 'next/navigation'
import { getSession } from '@/app/lib/session'
// FIX (boundary bug): the one real authorization check, called from every
// action that touches protected data — never imported into a Client
// Component, so 'server-only' can never leak across the 'use client' line.
export const verifySession = cache(async () => {
const session = await getSession()
if (!session) redirect('/login')
return session
})
app/blog/[slug]/actions.ts
'use server'
import { db } from '@/lib/db'
import { verifySession } from '@/app/lib/dal'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
// FIX (auth bug): authorize, then check the role, before mutating anything.
export async function publishPost(id: string, slug: string) {
const session = await verifySession()
if (session.role !== 'admin') {
return { error: 'Forbidden' }
}
await db.post.publish(id)
revalidatePath(`/blog/${slug}`)
// FIX (redirect bug): redirect() lives outside any try/catch now — nothing
// can swallow the throw.
redirect('/blog')
}
export async function addComment(slug: string, formData: FormData) {
const text = String(formData.get('text') ?? '')
const post = await db.post.findBySlug(slug)
if (!post) return
await new Promise((r) => setTimeout(r, 3000))
await db.comment.create(post.id, text)
revalidatePath(`/blog/${slug}`)
}
// FIX (prop bug): exported straight from a 'use server' module, so this is a
// Server Action reference, not a plain closure — safe to pass as a prop.
export async function deleteComment(slug: string, id: string) {
const session = await verifySession()
if (session.role !== 'admin') {
return { error: 'Forbidden' }
}
await db.comment.remove(id)
revalidatePath(`/blog/${slug}`)
}
app/blog/[slug]/publish-button.tsx
'use client'
import { useActionState } from 'react'
import { publishPost } from './actions'
type State = { error?: string } | null
export function PublishButton({ id, slug }: { id: string; slug: string }) {
const [state, formAction, pending] = useActionState<State, FormData>(
async () => (await publishPost(id, slug)) ?? null,
null,
)
return (
<form action={formAction}>
<button disabled={pending} type="submit">Publish</button>
{state?.error && <p role="alert">{state.error}</p>}
</form>
)
}
app/blog/[slug]/comment-list.tsx
type Comment = { id: string; text: string }
export function CommentList({
comments,
isAdmin,
onDelete,
}: {
comments: Comment[]
isAdmin: boolean
onDelete: (id: string) => Promise<{ error: string } | undefined>
}) {
return (
<ul>
{comments.map((c) => (
<li key={c.id}>
{c.text}
{isAdmin && <button onClick={() => onDelete(c.id)}>Delete</button>}
</li>
))}
</ul>
)
}
app/blog/[slug]/comment-form.tsx
'use client'
import { useFormStatus } from 'react-dom'
import { addComment } from './actions'
// FIX (useFormStatus bug): a child of <form> — the only place useFormStatus
// can see this form's pending state.
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending} type="submit">{pending ? 'Submitting...' : 'Add comment'}</button>
}
export function CommentForm({ slug }: { slug: string }) {
const action = addComment.bind(null, slug)
return (
<form action={action}>
<input name="text" />
<SubmitButton />
</form>
)
}
app/blog/[slug]/page.tsx
import { db } from '@/lib/db'
import { getSession } from '@/app/lib/session'
import { PublishButton } from './publish-button'
import { CommentForm } from './comment-form'
import { CommentList } from './comment-list'
import { deleteComment } from './actions'
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await db.post.findBySlug(slug)
if (!post) return <p>Not found</p>
const comments = await db.comment.listForPost(post.id)
// A page-level read only decides what UI to show — publishPost and
// deleteComment still verifySession() and check the role themselves.
const session = await getSession()
const isAdmin = session?.role === 'admin'
return (
<main>
<h1>{post.title}</h1>
<p>{post.body}</p>
<p>Published: {String(post.published)}</p>
{isAdmin && <PublishButton id={post.id} slug={post.slug} />}
<CommentList comments={comments} isAdmin={isAdmin} onDelete={deleteComment.bind(null, slug)} />
<CommentForm slug={slug} />
</main>
)
}

Built clean against Next.js 16.3.6 — next build reports 0 errors, 0 warnings. Requesting the page with no session cookie at all returns the page with no Publish button and no error; requesting it with a session=admin cookie shows the Publish button, and clicking it flips the post to published and redirects to /blog. Requesting it with a session=editor cookie (a real session, wrong role) and forcing the Publish button to render anyway shows the action itself rejects the write:

anonymous (no cookie), clicking Publish anyway -> redirected to /login (verifySession has no session at all)
session=editor (authenticated, wrong role) -> "Published: false" unchanged, page shows "Forbidden"
session=admin -> "Published: true", redirected to /blog
#BugCategoryFix
1publishPost never checked who was callingAuthCall verifySession() and check session.role === 'admin' before any write
2revalidateTag('posts') — one argumentData flowAdd the required profile argument, or use revalidatePath when there’s no tag to invalidate
3redirect('/blog') inside an unconditional try/catchErrors/redirectsMove redirect() outside the try/catch, or rethrow with unstable_rethrow
4AdminPanel ('use client') imported verifySession from a server-only moduleBoundariesNever import the DAL into a Client Component — pass a boolean like isAdmin down as a prop instead
5onDelete={(id) => console.log(...)} passed from a Server Component to a Client ComponentBoundariesPass an exported Server Action reference (deleteComment.bind(null, slug)), not an inline closure
6useFormStatus() called in the same component that renders <form>BoundariesMove the hook into a child SubmitButton component rendered inside the form

Every one of these six bugs compiles and looks plausible in isolation — that is exactly why an AI assistant writes them with full confidence. Three of them (server-only import, non-serializable prop, revalidateTag arity) are build-time facts the compiler already knows and will tell you the moment you build. The other three (missing auth, a swallowed redirect, a misplaced hook) are runtime facts that no compiler checks — the only way to catch them is to run the exact request that would trigger them, which is why this review used curl, a real next build, and a real browser instead of reasoning about the code from the page.

  • “It compiled and the page renders correctly, so it’s fine.” Four of these six bugs produce a perfectly normal-looking page. The Publish button flips and the page redirects (when it isn’t broken) — the bugs are all in who is allowed to trigger that, and what happens when something throws.
  • “The admin check on the Client Component is the security boundary.” AdminPanel deciding whether to render a button has nothing to do with whether publishPost accepts the request that button sends.
  • “A try/catch around a mutation is always the safe, defensive choice.” Here it hid the one thing (a redirect back to the fresh list) the feature needed to actually deliver.
flowchart TD
  A["1. Boundaries"] --> A1["server-only import into 'use client'"]
  A --> A2["non-serializable prop, Server to Client"]
  A --> A3["useFormStatus in the form's own component"]
  B["2. Data flow"] --> B1["revalidateTag with one argument"]
  C["3. Auth"] --> C1["publishPost with no verifySession()"]
  D["4. Errors/redirects"] --> D1["redirect() inside try/catch"]
Where each bug sits in the reading order
admin-panel.tsx starts with 'use client' and imports verifySession from app/lib/dal.ts, which starts with import 'server-only'. What happens when this builds?
Why does this fail to build, given that BlogPost has no 'use client' directive?
export default async function BlogPost() {
  return <CommentList onDelete={(id) => console.log(id)} />
}
In the buggy CommentForm, useFormStatus() is called directly inside the component that renders <form>. What is observed 50ms after clicking submit on a slow action?
publishPost never calls verifySession(). What happens when a completely anonymous visitor (no session cookie at all) clicks the rendered Publish button?
Requested for real after a successful publish, what does the server log show and where does the browser end up?
try {
  redirect('/blog')
} catch (err) {
  console.error(err)
}
What happens when this line is compiled against Next.js 16?
revalidateTag('posts')