Pages, Layouts และ Nested Routing
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”ใน App Router folder หนึ่งคือ route segment หนึ่ง page.tsx ทำให้ segment นั้นเข้าถึงเป็น URL ได้ ส่วน layout.tsx ห่อ segment นั้นพร้อม children ทุกตัว และยัง mount ค้างไว้ข้ามการ navigate
Folder = route segment
หัวข้อที่มีชื่อว่า “Folder = route segment”ทุก folder ใต้ app/ map ไปเป็น URL segment หนึ่ง segment จะกลายเป็น URL ที่เข้าถึงได้ก็ต่อเมื่อมี page.tsx อยู่ข้างใน
app/ layout.tsx // root layout (required) page.tsx // route: / dashboard/ layout.tsx // wraps /dashboard and its children page.tsx // route: /dashboard settings/ page.tsx // route: /dashboard/settingspage.tsx เป็น Server Component โดย default และ render UI เฉพาะของ segment นั้น
export default function DashboardPage() { return <h1>Dashboard</h1>}root layout ที่ต้องมี
หัวข้อที่มีชื่อว่า “root layout ที่ต้องมี”ทุกแอป App Router ต้องมี root layout หนึ่งตัว เป็น layout เดียวที่ render tag <html> และ <body> และต้อง render children เสมอ
// app/layout.tsx — the required root layoutexport default function RootLayout({ children,}: { children: React.ReactNode}) { return ( <html lang="en"> <body>{children}</body> </html> )}nested layout persist และ compose
หัวข้อที่มีชื่อว่า “nested layout persist และ compose”layout.tsx ห่อ segment ของตัวเองและ descendant ทั้งหมด nested layout compose จากนอกเข้าใน root ห่อ dashboard ห่อ settings จุดสำคัญคือ layout จะ persist ข้ามการ navigate เมื่อย้ายระหว่าง route พี่น้องกัน layout ที่ใช้ร่วมจะไม่ re-render ทำให้ state และตำแหน่ง scroll ถูกเก็บไว้
// app/dashboard/layout.tsx — persists across navigationexport default function DashboardLayout({ children,}: { children: React.ReactNode}) { return ( <section> <nav>{/* stays mounted, keeps scroll + local state */}</nav> <main>{children}</main> </section> )}loading.tsx และ error.tsx
หัวข้อที่มีชื่อว่า “loading.tsx และ error.tsx”ใส่ loading.tsx แล้ว Next.js จะห่อ segment ด้วย Suspense boundary ให้อัตโนมัติ fallback จะโชว์ทันทีระหว่างที่งานฝั่ง server ยัง stream เข้ามา
// app/dashboard/loading.tsx — automatic Suspense boundaryexport default function Loading() { return <p>Loading dashboard…</p>}ใส่ error.tsx แล้ว Next.js จะห่อ segment ด้วย error boundary ไฟล์นี้ต้องเป็น Client Component ณ Next.js 16.3.6 มันรับทั้ง retry() (stable ตั้งแต่ v16.3.0 — re-fetch แล้ว re-render segment ใหม่ เป็นตัวที่ docs ปัจจุบันแนะนำ) และ reset() แบบเดิม (เคลียร์ error state ของ boundary โดยไม่ fetch ซ้ำ) โค้ดที่ AI สร้างจากตัวอย่างเก่ามักเขียนแค่ reset เท่านั้น ทั้งคู่ยังใช้ได้ แต่ retry คือตัวที่ docs ของ Next.js ชูเป็นหลักตอนนี้
'use client' // error boundaries must be Client Components
export default function Error({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <div> <h2>Something went wrong</h2> <button onClick={() => retry()}>Try again</button> </div> )}ลำดับชั้นของ error boundary: error ของ layout เองหลุดขึ้นไปข้างบน
หัวข้อที่มีชื่อว่า “ลำดับชั้นของ error boundary: error ของ layout เองหลุดขึ้นไปข้างบน”error.tsx ห่อ loading.tsx, not-found.tsx, page.tsx และ nested layout.tsx ที่อยู่ใต้ segment เดียวกัน — แต่มันไม่ห่อ layout.tsx (หรือ template.tsx) ที่อยู่ folder เดียวกันกับมันเอง ถ้า layout render แล้ว throw เอง error boundary ที่อยู่ folder เดียวกันจะไม่มีวันเห็น error นั้นจะลอยขึ้นไปเรื่อย ๆ จนถึง error.tsx ของ segment แม่
// app/settings/layout.tsx — this layout's own render throwsexport default function SettingsLayout({ children,}: { children: React.ReactNode}) { throw new Error('layout boom') return <section>{children}</section>}// app/settings/error.tsx — same segment as the layout above: does NOT catch it'use client'
export default function SettingsError({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <div> <h2>SETTINGS ERROR (same segment)</h2> <button onClick={() => retry()}>retry</button> </div> )}// app/error.tsx — the PARENT segment's boundary: this is the one that actually catches it'use client'
export default function RootError({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <div> <h2>ROOT ERROR BOUNDARY</h2> <button onClick={() => retry()}>retry</button> </div> )}Build และ start จริง (next build && next start) แล้วเปิดในเบราว์เซอร์ ข้อความที่ render ออกมาพิสูจน์ตรง ๆ ว่า boundary ไหนที่ทำงานจริง
document.body.innerText ->"root-navROOT ERROR BOUNDARY
Minified React error #441 …
retry"SETTINGS ERROR (same segment) ไม่โผล่มาเลย — layout ที่ throw กับ error boundary ที่ควรจะห่อมันอยู่ใน folder เดียวกัน (พี่น้องกัน) และ error.tsx ห่อแค่สิ่งที่อยู่ ใต้ มัน ไม่ใช่ layout ที่มันอยู่ข้าง ๆ
global-error.tsx แทนที่ root layout ทั้งก้อน
หัวข้อที่มีชื่อว่า “global-error.tsx แทนที่ root layout ทั้งก้อน”กฎเดียวกันนี้ใช้กับชั้นบนสุดเช่นกัน แต่มีจุดพลิก: root layout.tsx ไม่มี parent segment ของตัวเอง เลยไม่มีอะไรห่อมันเลย — แม้แต่ app/error.tsx ของ root เอง ถ้า root layout เอง throw สิ่งเดียวที่จับได้คือ global-error.tsx และเพราะมันแทนที่ root layout ทั้งก้อน มันจึงต้อง render tag <html> และ <body> ของตัวเอง
'use client' // error boundaries must be Client Components
export default function GlobalError({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <html> <body> <h2>Something went seriously wrong</h2> <button onClick={() => retry()}>Try again</button> </body> </html> )}พิสูจน์แบบเดียวกัน: พอ root layout เองเป็นตัว throw ข้อความของ app/error.tsx ไม่โผล่มาเลย — มีแค่ global-error.tsx เท่านั้นที่ render
document.body.innerText ->"GLOBAL ERROR (root layout itself crashed)retry"graph TD RL["app/layout.tsx (root layout)"] -->|"throws"| GE["global-error.tsx (only thing above root)"] SL["app/settings/layout.tsx (throws)"] -->|"NOT caught by"| SE["app/settings/error.tsx (same segment)"] SL -->|"caught by"| RE["app/error.tsx (parent segment)"] RE -->|"wraps"| SP["app/settings/page.tsx"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”อ่าน error.tsx ว่าเป็น “boundary สำหรับทุกอย่างข้างในfolder นี้” — ไม่ใช่ “boundary สำหรับ layout ของ folder นี้เอง” layout กับ error.tsx ใน folder เดียวกันเป็นพี่น้องกัน ไม่ใช่พ่อลูก ดังนั้น error ของ layout เองต้องลอยขึ้นไปหา ancestor segment ที่ห่อมันจริง ๆ root layout คือกรณีสุดขั้ว: มันไม่มี ancestor เลยใน App Router tree เพราะฉะนั้นมีแค่ global-error.tsx — ซึ่งอยู่นอก tree นั้นไปเลยและสร้าง <html>/<body> เอง — เท่านั้นที่จับมันได้
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ไฟล์ | ห่ออะไร | persist ข้ามการ navigate ไหม |
|---|---|---|
layout.tsx | segment ของตัวเอง + descendant ทั้งหมด | ใช่ — ไม่ re-render ตอน navigate ระหว่าง sibling |
loading.tsx | ห่อ segment ด้วย Suspense boundary | fallback โชว์ต่อการ navigate ไม่ persist |
error.tsx (segment เดียวกัน) | ไฟล์ loading/not-found/page/nested layout | ไม่ห่อ layout ที่มันอยู่ข้าง ๆ |
error.tsx (parent segment) | จับ error ของ layout ลูกที่ render เอง | boundary segment เดียวกันของ root layout ไม่มี parent — เลยต้องมี global-error.tsx |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
error.tsxจับ error ทุกอย่างใน folder ตัวเอง รวมถึง layout ด้วย” พิสูจน์แล้วว่าผิดด้านบน:settings/layout.tsxcrash แล้วsettings/error.tsxมองไม่เห็นเลย มีแค่ boundary ของ parent segment เท่านั้นที่จับได้ - “
reset()คือ prop เดียวที่error.tsxได้รับ” ตั้งแต่v16.3.0retry()คือ prop ที่ stable และแนะนำให้ใช้ — มัน re-fetch แล้ว re-render ส่วนreset()แค่เคลียร์ error state ของ boundary เท่านั้น - “root layout ก็ถูก
app/error.tsxป้องกันเหมือนที่อื่น” ไม่จริง — root layout คือจุดเดียวใน tree ที่ไม่มี parent segment ดังนั้น error ของมันเองไปถึงแค่global-error.tsxเท่านั้น
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างเพิ่ม loading.tsx/error.tsx/global-error.tsx ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ
error.tsxขาด'use client'ไหม มันต้องเป็น Client Component เสมอ — เวอร์ชัน Server Component จะพังตอน build/runtime ไม่ใช่แค่ lint warning- โค้ดสมมติว่า
error.tsxจะจับ crash ที่มาจาก layout ใน folder เดียวกัน ไหม มันจะไม่จับ — error นั้นหลุดไปหา parent segment แทน global-error.tsxขาด tag<html>/<body>ของตัวเองไหม มันแทนที่ root layout ทั้งก้อน ถ้าไม่มี tag พวกนี้หน้าจะไม่มี document shell เลย- โค้ด destructure แค่
resetโดยไม่พูดถึงretryเลยไหม ไม่ผิด แต่เป็น API แบบเก่า —retry()คือตัวที่ docs ปัจจุบันของ Next.js ชูเป็นหลัก loading.tsxทำงาน async จริง ๆ เองไหม (แทนที่จะแค่ render fallback) Suspense boundary อัตโนมัติคือตัวที่ stream segment ข้างใต้เข้ามา —loading.tsxเองควรเป็น fallback แบบ synchronous ที่โชว์ทันที
🐛 หา bug ในโค้ดนี้
export default function DashboardLayout({children,}: {children: React.ReactNode}) {const stats = fetchDashboardStats() // throws if the API is downreturn ( <section> <Header stats={stats} /> {children} </section>)}
// app/dashboard/error.tsx'use client'export default function DashboardError({error,reset,}: {error: Errorreset: () => void}) {return <button onClick={() => reset()}>Retry loading the dashboard</button>}เฉลย
คนเขียนโค้ดนี้ใส่ data fetch ที่เสี่ยง error ไว้ตรง ๆ ใน layout.tsx แล้วสมมติว่า error.tsx ใน folder dashboard/ เดียวกันจะจับ failure จากมันได้ มันจะไม่จับ: error.tsx ห่อ page.tsx, loading.tsx, not-found.tsx และ nested layout ที่อยู่ใต้มัน แต่ไม่ห่อ layout ที่มันอยู่ folder เดียวกัน พิสูจน์แล้วด้วย next build && next start จริง: crash โผล่ที่ boundary ของ segment แม่ แทน — ในแอปที่ dashboard/ อยู่ตรงใต้ root แปลว่าคือ app/error.tsx (หรือ global-error.tsx ถ้าเป็น root layout เอง) วิธีแก้คือย้าย data fetch ไปไว้ใน page.tsx แทน (ให้มี child boundary จริงห่อมันได้) หรือออกแบบให้ parent boundary เป็นคนจับตั้งแต่แรก