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

Pages, Layouts และ Nested Routing

ใน App Router folder หนึ่งคือ route segment หนึ่ง page.tsx ทำให้ segment นั้นเข้าถึงเป็น URL ได้ ส่วน layout.tsx ห่อ segment นั้นพร้อม children ทุกตัว และยัง mount ค้างไว้ข้ามการ navigate

ทุก 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/settings

page.tsx เป็น Server Component โดย default และ render UI เฉพาะของ segment นั้น

app/dashboard/page.tsx
export default function DashboardPage() {
return <h1>Dashboard</h1>
}

ทุกแอป App Router ต้องมี root layout หนึ่งตัว เป็น layout เดียวที่ render tag <html> และ <body> และต้อง render children เสมอ

// app/layout.tsx — the required root layout
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

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 navigation
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<section>
<nav>{/* stays mounted, keeps scroll + local state */}</nav>
<main>{children}</main>
</section>
)
}

ใส่ loading.tsx แล้ว Next.js จะห่อ segment ด้วย Suspense boundary ให้อัตโนมัติ fallback จะโชว์ทันทีระหว่างที่งานฝั่ง server ยัง stream เข้ามา

// app/dashboard/loading.tsx — automatic Suspense boundary
export 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 ชูเป็นหลักตอนนี้

app/dashboard/error.tsx
'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 throws
export 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-nav
ROOT ERROR BOUNDARY
Minified React error #441 …
retry"

SETTINGS ERROR (same segment) ไม่โผล่มาเลย — layout ที่ throw กับ error boundary ที่ควรจะห่อมันอยู่ใน folder เดียวกัน (พี่น้องกัน) และ error.tsx ห่อแค่สิ่งที่อยู่ ใต้ มัน ไม่ใช่ layout ที่มันอยู่ข้าง ๆ

กฎเดียวกันนี้ใช้กับชั้นบนสุดเช่นกัน แต่มีจุดพลิก: root layout.tsx ไม่มี parent segment ของตัวเอง เลยไม่มีอะไรห่อมันเลย — แม้แต่ app/error.tsx ของ root เอง ถ้า root layout เอง throw สิ่งเดียวที่จับได้คือ global-error.tsx และเพราะมันแทนที่ root layout ทั้งก้อน มันจึงต้อง render tag <html> และ <body> ของตัวเอง

app/global-error.tsx
'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"]
boundary ไหนจับ error ตัวไหนจริง ๆ

อ่าน 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.tsxsegment ของตัวเอง + descendant ทั้งหมดใช่ — ไม่ re-render ตอน navigate ระหว่าง sibling
loading.tsxห่อ segment ด้วย Suspense boundaryfallback โชว์ต่อการ 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.tsx crash แล้ว settings/error.tsx มองไม่เห็นเลย มีแค่ boundary ของ parent segment เท่านั้นที่จับได้
  • “reset() คือ prop เดียวที่ error.tsx ได้รับ” ตั้งแต่ v16.3.0 retry() คือ 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 สร้างเพิ่ม 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 ในโค้ดนี้

app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const stats = fetchDashboardStats() // throws if the API is down
return (
<section>
<Header stats={stats} />
{children}
</section>
)
}
// app/dashboard/error.tsx
'use client'
export default function DashboardError({
error,
reset,
}: {
error: Error
reset: () => 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 เป็นคนจับตั้งแต่แรก

อะไรทำให้ folder segment เข้าถึงเป็น route ได้
layout มีความพิเศษอย่างไรตอน navigate
มี app/settings/error.tsx จริงอยู่ใน folder เดียวกันด้วย build และ request จริง ข้อความของ boundary ไหนที่ render ออกมา
// app/settings/layout.tsx
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
  throw new Error('boom')
  return <section>{children}</section>
}
error.tsx ต้องเป็นอะไร และรับอะไรบ้าง ณ Next.js 16.3.6
ทำไม global-error.tsx ต้อง render tag <html> และ <body> ของตัวเอง
มีทั้ง app/error.tsx และ app/global-error.tsx อยู่ ข้อความของอันไหนที่ render จริง
// app/layout.tsx (root)
export default function RootLayout({ children }) {
  throw new Error('root boom')
  return <html><body>{children}</body></html>
}