ข้ามไปยังเนื้อหา

รีวิวโค้ดชุดที่ 1: บล็อกที่มีคอมเมนต์และปุ่ม publish สำหรับแอดมิน

โจทย์ที่ให้ AI คือ: “เพิ่มหน้าโพสต์บล็อกที่มีคอมเมนต์ แอดมินที่ login แล้วจะเห็นปุ่ม Publish สำหรับเปลี่ยนโพสต์จาก draft เป็น published” ผลลัพธ์ด้านล่างคือแบบที่ AI assistant เขียนให้ในรอบแรก — compile ผ่าน, next build จบด้วย error เป็นศูนย์ และทุกชิ้นส่วนก็หน้าตาเหมือนสิ่งที่เคยเห็นในคอร์สนี้มาแล้ว อ่านตามลำดับการอ่านที่โมดูลนี้แนะนำ: boundary ก่อน แล้วค่อย data flow, auth, และ error path โค้ดชุดนี้มี bug จริง 6 จุด ที่ next build จับไม่ได้เลยสักจุด

// 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>
)
}

admin-panel.tsx ขึ้นต้นด้วย 'use client' แล้ว import verifySession จาก app/lib/dal.ts — ไฟล์ที่ขึ้นต้นด้วย import 'server-only' เอง จุดประสงค์ทั้งหมดของ verifySession คือเป็นจุดเดียวที่ authorize ผู้เรียก แต่กลับถูกดึงเข้าไปอยู่ใน bundle ของ browser แทน

🐛 หา 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>
)
}
เฉลย

'use client' ทำให้ทั้งโมดูลนี้เป็น Client Component และทุกอย่างที่มัน import ต้องปลอดภัยพอที่จะส่งไปที่ browser app/lib/dal.ts ขึ้นต้นด้วย import 'server-only' เพื่อป้องกันเรื่องนี้โดยเฉพาะ — และ Turbopack จับได้ตอน build ไม่ใช่ตอน 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.

นี่คือ build error ไม่ใช่แค่ warning — โปรเจกต์ deploy ไม่ได้จนกว่าจะแก้ วิธีแก้คือย้าย authorization check ไปอยู่ในตัว Server Action publishPost เอง (ดูรีวิว auth/caching ด้านล่าง) แล้ว AdminPanel ก็กลับไปเป็นฟอร์ม client ธรรมดาที่ไม่ import DAL เลย

page.tsx เป็น Server Component — ไม่มี 'use client' อยู่บนสุด จึง render บนฝั่งเซิร์ฟเวอร์ แต่ส่ง arrow function แบบ inline เป็น prop onDelete ให้ CommentList ซึ่งเป็น Client Component

🐛 หา 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)} />
)
}
เฉลย

prop ที่ข้ามจาก Server Component ไปยัง Client Component ต้อง serialize ได้ — Next.js ต้องแปลงมันเป็น RSC payload ที่ส่งไป browser ได้ แต่ closure ไม่มีรูปแบบที่ serialize ได้เลย build หน้านี้จริงแล้วล้มเหลวตอน prerender ไม่ใช่ที่บรรทัด import แต่ที่จุดที่ค่านั้นถูกใช้จริง:

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.

วิธีแก้ไม่ใช่เปลี่ยน page.tsx เองให้เป็น Client Component (จะดันการ fetch ข้อมูลทั้งหน้าไปที่ browser) — แต่คือส่ง Server Action reference แทน closure ฟังก์ชันที่ export จากไฟล์ 'use server' จะถูกจดจำและ serialize ข้ามขอบเขตได้เป็นพิเศษ ส่วน arrow function แบบ inline ธรรมดาไม่ได้

CommentForm เรียก useFormStatus() ตรงๆ ในคอมโพเนนต์เดียวกับที่ render <form>

🐛 หา 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>
)
}
เฉลย

useFormStatus รายงานสถานะ pending ของ <form> ที่เป็น ancestor ที่ใกล้ที่สุดใน render tree — และคอมโพเนนต์ที่ render <form> เองไม่ใช่ descendant ของตัวมันเอง โค้ดนี้ compile และรันได้โดยไม่มี error หรือ warning เลย มันแค่ไม่เคยทำงาน กรอกช่องข้อความแล้วกด submit จากนั้นอ่านสถานะ DOM ของปุ่มหลังจากนั้น 50ms ในเบราว์เซอร์จริงกับโค้ดนี้เป๊ะๆ:

text: "Add comment"
disabled: false

คอมเมนต์ยังกำลังถูกเขียนอยู่ (addComment ใน demo นี้ใช้เวลา 3 วินาที) แต่ปุ่มไม่เคยแสดงสถานะนั้นเลย วิธีแก้คือย้าย useFormStatus ไปอยู่ใน child component แยกต่างหากที่ render อยู่ข้างใน <form> เหมือนที่บทฟอร์มและ progressive enhancementแสดงไว้ตั้งแต่แรก

Data flow: revalidateTag — ในฟีเจอร์นี้ไม่ได้ใช้ แต่รูปแบบอาร์กิวเมนต์เดียวคือ type error

หัวข้อที่มีชื่อว่า “Data flow: revalidateTag — ในฟีเจอร์นี้ไม่ได้ใช้ แต่รูปแบบอาร์กิวเมนต์เดียวคือ type error”

ฟีเจอร์นี้เรียกแค่ revalidatePath ซึ่งรับอาร์กิวเมนต์เดียวและใช้ได้ปกติ แต่โค้ดร่างก่อนหน้าของฟีเจอร์เดียวกันนี้ที่ AI เขียน ใช้ tag-based invalidation แทน และเขียนการเรียกผิดแบบที่ revalidateTag มักถูกเขียนผิดจากความจำ:

🐛 หา bug ในโค้ดนี้

import { revalidateTag } from 'next/cache'
export async function publishPost(id: string) {
await db.post.publish(id)
revalidateTag('posts')
}
เฉลย

ใน Next.js 16 อาร์กิวเมนต์ตัวที่สองของ revalidateTag — cache-life profile — เป็นสิ่งจำเป็น รูปแบบอาร์กิวเมนต์เดียวไม่ใช่แค่ runtime warning แต่เป็น compile error:

error TS2554: Expected 2 arguments, but got 1.

tsc --noEmit จับจุดนี้ได้ก่อนที่โค้ดจะไปถึง next build ด้วยซ้ำ วิธีแก้คือ revalidateTag('posts', 'max') — หรือในเมื่อ action นี้มี path ของโพสต์อยู่ในมืออยู่แล้ว ก็ใช้ revalidatePath แบบในโค้ดที่ AI เขียนด้านบน ซึ่งไม่ต้องมีอาร์กิวเมนต์ profile เลย

ทีนี้มาดู action ที่รันจริง bug ของ AdminPanel ซ่อน bug ที่ร้ายแรงกว่าไว้อีกจุดหนึ่ง: พอย้าย verifySession ออกจาก Client Component แล้ว มันควรไปอยู่ที่ไหน?

🐛 หา 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)
}
}
เฉลย

publishPost เป็น Server Action — คือ public POST endpoint ที่เรียกได้จาก action ID ของมันจากที่ไหนก็ได้ ตามที่บทServer Actionsกำหนดไว้ action นี้ไม่เคยเรียก verifySession() หรือเช็ค role ใดๆ เลย มันแค่ publish อย่างเดียว build และยิง request จริง จาก browser tab ที่ไม่มี cookie เลยสักตัว — ไม่มี session ไม่มี login ไม่มีอะไรทั้งนั้น — กดปุ่ม Publish ที่ render อยู่แล้วโพสต์ก็เปลี่ยนจาก draft เป็น published:

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

การที่ปุ่ม Publish ถูก render ออกมาหรือไม่ ไม่เกี่ยวอะไรกับเรื่องนี้เลย — ต่อให้ page.tsx render AdminPanel เฉพาะสำหรับแอดมินที่ login แล้ว ตัว action ก็ไม่รู้อยู่ดีว่าใครเป็นคนเรียก การ gate ตอน render ตัดสินว่า UI แสดงอะไร แต่ไม่ได้บอกอะไรเลยว่าเซิร์ฟเวอร์ยอมรับอะไรบ้าง

action publishPost เดียวกันนี้มี bug ที่สองที่แยกออกจากกัน หลังแก้ตัวแรกแล้ว: การ redirect กลับไปหน้ารายการบล็อกหลัง publish สำเร็จไม่เคยเกิดขึ้นเลย

🐛 หา bug ในโค้ดนี้

try {
redirect('/blog')
} catch (err) {
console.error('publishPost: caught instead of redirecting:', err)
}
เฉลย

redirect() ทำงานโดยการ throw control-flow error ที่ Next.js จะดักจับที่ระดับสูงกว่าใน tree try/catch นี้ไม่มีการกรองใดๆ เลย — มันดักจับ throw นั้นเหมือนกับ error ทั่วไป แล้ว log แล้วก็กลืนมันไป build, start จริง แล้วคลิกผ่าน browser: URL ของหน้าไม่เคยเปลี่ยนออกจาก /blog/hello-world และ log ของเซิร์ฟเวอร์ก็แสดงสิ่งที่ถูกดักจับไว้พอดี:

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;'
}

digest string คือรูปแบบเข้ารหัสของ Next.js เองสำหรับปลายทาง (/blog), ชนิดของการนำทาง (push ค่า default เมื่อ redirect() ถูกเรียกจาก Server Action ที่ผูกกับฟอร์ม) และ status (307) — คำนวณไว้หมดแล้ว แต่ถูก catch block นี้ทิ้งไป วิธีแก้ เหมือนที่บทmutations และ revalidationพิสูจน์ไว้สำหรับ notFound() คือให้ redirect() อยู่นอก try/catch ที่ไม่ rethrow อย่างชัดเจน

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>
)
}

build ผ่านสะอาดกับ Next.js 16.3.6 — next build รายงาน error เป็นศูนย์ warning เป็นศูนย์ ยิง request หน้านี้โดยไม่มี session cookie เลยจะได้หน้าที่ไม่มีปุ่ม Publish และไม่มี error; ยิงพร้อม cookie session=admin จะเห็นปุ่ม Publish และกดแล้วโพสต์เปลี่ยนเป็น published พร้อม redirect ไป /blog ยิงพร้อม cookie session=editor (มี session จริงแต่ role ผิด) แล้วบังคับให้ปุ่ม Publish render ออกมาด้วย จะเห็นว่าตัว action เองปฏิเสธการเขียน:

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
#Bugหมวดวิธีแก้
1publishPost ไม่เคยเช็คว่าใครเป็นคนเรียกAuthเรียก verifySession() แล้วเช็ค session.role === 'admin' ก่อนเขียนข้อมูลใดๆ
2revalidateTag('posts') — อาร์กิวเมนต์เดียวData flowเพิ่มอาร์กิวเมนต์ profile ที่จำเป็น หรือใช้ revalidatePath เมื่อไม่มี tag ให้ invalidate
3redirect('/blog') อยู่ใน try/catch แบบไม่มีเงื่อนไขErrors/redirectsย้าย redirect() ออกนอก try/catch หรือ rethrow ด้วย unstable_rethrow
4AdminPanel ('use client') import verifySession จากโมดูลที่เป็น server-onlyBoundariesห้าม import DAL เข้า Client Component เด็ดขาด — ส่ง boolean อย่าง isAdmin เป็น prop ลงไปแทน
5onDelete={(id) => console.log(...)} ส่งจาก Server Component ไปยัง Client ComponentBoundariesส่ง Server Action reference ที่ export ไว้ (deleteComment.bind(null, slug)) แทน closure แบบ inline
6useFormStatus() ถูกเรียกในคอมโพเนนต์เดียวกับที่ render <form>Boundariesย้าย hook ไปไว้ใน component ลูกชื่อ SubmitButton ที่ render อยู่ข้างในฟอร์ม

Bug ทั้ง 6 จุดนี้ compile ผ่านและดูสมเหตุสมผลเมื่อดูแยกกัน — นี่คือเหตุผลที่ AI assistant เขียนมันออกมาด้วยความมั่นใจเต็มที่ สามจุด (import server-only, prop ที่ serialize ไม่ได้, arity ของ revalidateTag) เป็นข้อเท็จจริงตอน build ที่ compiler รู้อยู่แล้วและจะบอกทันทีที่ build ส่วนอีกสามจุด (auth หายไป, redirect ถูกกลืน, hook อยู่ผิดที่) เป็นข้อเท็จจริงตอน runtime ที่ไม่มี compiler ตัวไหนเช็คให้ — วิธีเดียวที่จะจับได้คือรัน request จริงที่จะกระตุ้นมัน ซึ่งเป็นเหตุผลที่รีวิวนี้ใช้ curl, การ next build จริง และ browser จริง แทนที่จะนั่งอ่านโค้ดเฉยๆ

  • “compile ผ่านและหน้าเว็บ render ถูกต้อง แปลว่าใช้ได้แล้ว” สี่ในหกจุดนี้ทำให้หน้าเว็บดูปกติสมบูรณ์แบบ ปุ่ม Publish ก็ทำงาน หน้าก็ redirect (เมื่อไม่ได้พัง) — bug ทั้งหมดอยู่ที่ว่าใครมีสิทธิ์กระตุ้นมันได้ และเกิดอะไรขึ้นเมื่อมันเกิด throw
  • “การเช็คแอดมินใน Client Component คือ security boundary” การที่ AdminPanel ตัดสินใจว่าจะ render ปุ่มหรือไม่ ไม่เกี่ยวอะไรเลยกับว่า publishPost จะยอมรับ request ที่ปุ่มนั้นส่งไปหรือไม่
  • “try/catch รอบการเขียนข้อมูลคือทางเลือกที่ปลอดภัยที่สุดเสมอ” ในกรณีนี้มันซ่อนสิ่งเดียว (redirect กลับไปหน้ารายการที่เพิ่งอัปเดต) ที่ฟีเจอร์นี้ต้องการส่งมอบจริงๆ
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"]
แต่ละ bug อยู่ตรงไหนในลำดับการอ่าน
admin-panel.tsx ขึ้นต้นด้วย 'use client' และ import verifySession จาก app/lib/dal.ts ซึ่งขึ้นต้นด้วย import 'server-only' เกิดอะไรขึ้นตอน build?
ทำไมโค้ดนี้ build ไม่ผ่าน ทั้งที่ BlogPost ไม่มี directive 'use client' เลย?
export default async function BlogPost() {
  return <CommentList onDelete={(id) => console.log(id)} />
}
ใน CommentForm เวอร์ชันที่มี bug useFormStatus() ถูกเรียกตรงๆ ในคอมโพเนนต์เดียวกับที่ render <form> สิ่งที่สังเกตได้ 50ms หลังกด submit บน action ที่ทำงานช้าคืออะไร?
publishPost ไม่เคยเรียก verifySession() เกิดอะไรขึ้นเมื่อผู้เยี่ยมชมที่ไม่ระบุตัวตนโดยสิ้นเชิง (ไม่มี session cookie เลย) กดปุ่ม Publish ที่ render อยู่?
ยิง request จริงหลัง publish สำเร็จ server log แสดงอะไรและ browser ไปจบที่ไหน?
try {
  redirect('/blog')
} catch (err) {
  console.error(err)
}
เกิดอะไรขึ้นเมื่อบรรทัดนี้ถูก compile กับ Next.js 16?
revalidateTag('posts')