File Conventions และ Special Files
ไอเดียหลักในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียหลักในหนึ่งประโยค”ใน App Router โฟลเดอร์นิยาม route segment และชื่อไฟล์ที่จองไว้ชุดเล็ก ๆ ให้พฤติกรรมกับแต่ละโฟลเดอร์ — โฟลเดอร์จะเข้าถึงเป็น URL ได้ก็ต่อเมื่อมี page อยู่ข้างใน
โฟลเดอร์คือ segment ส่วนไฟล์พิเศษคือพฤติกรรม
หัวข้อที่มีชื่อว่า “โฟลเดอร์คือ segment ส่วนไฟล์พิเศษคือพฤติกรรม”โฟลเดอร์ใต้ app/ คือ URL segment แต่โฟลเดอร์เปล่า ๆ ไม่ทำอะไร สิ่งที่ทำถูกตัดสินโดยไฟล์ที่จองไว้ข้างใน ชื่อไฟล์แต่ละอันมีหน้าที่เดียว —
page.tsx— UI ของ route และเป็นตัวที่ทำให้ segment นั้น route ได้แบบ publiclayout.tsx— wrapper ที่ใช้ร่วมกัน คงอยู่ข้ามการ navigate และ nest ซ้อนใน parent layoutloading.tsx— Suspense fallback ที่แสดงระหว่าง segment กำลัง stream เข้ามาerror.tsx— error boundary ของ segment และต้องเป็น Client Componentnot-found.tsx— UI ที่ render เมื่อnotFound()ถูกเรียกroute.ts— Route Handler คือ API endpoint แทนที่จะเป็น pagetemplate.tsx— คล้าย layout แต่ re-mount instance ใหม่ทุกครั้งที่ navigate
app/ layout.tsx # root layout — wraps everything page.tsx # "/" dashboard/ layout.tsx # wraps everything under /dashboard loading.tsx # fallback while /dashboard loads error.tsx # catches errors in /dashboard page.tsx # "/dashboard" api/ users/ route.ts # "/api/users" — a Route Handler, not a pagepage กับ layout คือสองอันที่คุณใช้บ่อยสุด
หัวข้อที่มีชื่อว่า “page กับ layout คือสองอันที่คุณใช้บ่อยสุด”page รับ route param และ search param แล้ว return UI ของ segment —
export default async function BlogPost({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; return <article>Reading: {slug}</article>;}layout ครอบ segment และทุกอย่างที่อยู่ข้างใต้ และเก็บ state ไว้ข้ามการ navigate ระหว่าง sibling page ดังนั้น chrome ที่ใช้ร่วมกันอย่าง sidebar จึงไม่ remount —
export default function DashboardLayout({ children,}: { children: React.ReactNode;}) { return ( <section> <nav>Dashboard nav</nav> {children} </section> );}root layout เป็นสิ่งที่ต้องมี
หัวข้อที่มีชื่อว่า “root layout เป็นสิ่งที่ต้องมี”ทุก App Router app ต้องมี root layout หนึ่งอันที่ app/layout.tsx เป็น layout เดียวที่ render tag <html> และ <body> เพราะ Next.js ไม่ได้ปล่อย tag พวกนี้ให้คุณ —
export default function RootLayout({ children,}: { children: React.ReactNode;}) { return ( <html lang="en"> <body>{children}</body> </html> );}มีสองกฎที่ตามมาจากทั้งหมดนี้ กฎแรก โฟลเดอร์ที่ไม่มี page.tsx (หรือ route.ts) จะ route ไม่ได้ — คุณ nest โฟลเดอร์เพื่อจัดระเบียบไฟล์ล้วน ๆ ได้ และจะไม่ตอบสนองต่อ URL เลย กฎที่สอง error.tsx ต้องขึ้นต้นด้วย "use client" เพราะ error boundary พึ่งพา React ฝั่ง client
flowchart LR Folder[dashboard folder] --> Layout[layout wraps and persists] Layout --> Loading[loading is the Suspense fallback] Layout --> Error[error catches failures] Layout --> Page[page is the routable UI] Page --> URL[/dashboard is reachable]
ตารางอ้างอิง special files แบบเต็ม
หัวข้อที่มีชื่อว่า “ตารางอ้างอิง special files แบบเต็ม”page กับ layout คือสองอันที่คุณใช้ทุกวัน แต่ App Router จองชื่อไฟล์ไว้อีกยาวกว่านั้นมาก แต่ละอันตรวจสอบแล้วกับ Next.js docs ที่ bundle มา (node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/) —
| ไฟล์ | ทำอะไร | สถานะ |
|---|---|---|
page.tsx | UI ของ segment ทำให้มัน route ได้แบบ public | Stable |
layout.tsx | wrapper ที่ใช้ร่วมกัน คงอยู่ข้าม sibling navigation อันที่เป็น root ต้อง render <html>/<body> | Stable |
template.tsx | เหมือน layout แต่ re-mount instance ใหม่ทุกครั้งที่ navigate แทนที่จะคงอยู่ | Stable |
loading.tsx | <Suspense> fallback ที่โชว์ระหว่าง segment กำลัง stream เข้ามา | Stable |
error.tsx | error boundary แบบ Client Component ของ segment | Stable |
not-found.tsx | UI ที่ render เมื่อ notFound() ถูกเรียก หรือ segment ไม่ match | Stable |
default.tsx | UI สำรองของ slot ใน Parallel Routes เมื่อ Next.js กู้ active state คืนไม่ได้ตอน hard navigation | Stable |
global-error.tsx | error boundary ระดับ root — ตัวเดียวที่จับ error จาก root layout/template เองได้ ต้อง define <html>/<body> เอง และ export metadata ไม่ได้ | Stable |
forbidden.tsx | render เมื่อ forbidden() ถูกเรียก Next.js คืน 403 | Experimental |
unauthorized.tsx | render เมื่อ unauthorized() ถูกเรียก Next.js คืน 401 | Experimental |
route.ts | Route Handler — GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS — แทนที่จะเป็น page | Stable |
proxy.ts | รันก่อน request จะเสร็จ บนทุก route ที่ match (ไฟล์ที่ Next.js 16 เปลี่ยนชื่อจาก middleware.ts) ดู Proxy and the Request Lifecycle | Stable |
instrumentation.ts | export register() (optional, รันครั้งเดียวต่อ server instance) และ onRequestError() (optional) สำหรับ observability อยู่ที่ root เท่านั้น | Stable |
instrumentation-client.ts | คู่หูฝั่ง client — รันก่อน app จะ interactive สำหรับ monitoring/analytics/polyfill ไม่ต้อง export อะไรเป็นพิเศษ | Stable |
mdx-components.tsx | จำเป็นสำหรับใช้ @next/mdx กับ App Router export ฟังก์ชัน useMDXComponents() เดียว | Stable |
sitemap.ts / sitemap.xml | generate (หรือ define แบบ static) /sitemap.xml | Stable |
robots.ts / robots.txt | generate (หรือ define แบบ static) /robots.txt แบบ generate ถูก cache โดย default เหมือน Route Handler ทั่วไป | Stable |
opengraph-image.tsx / twitter-image.tsx | generate รูปสำหรับแชร์บน social ของ segment ด้วย ImageResponse หรือใส่ไฟล์รูป static ชื่อเดียวกัน | Stable |
forbidden.tsx กับ unauthorized.tsx เป็นสองตัวเดียวในตารางนี้ที่ยังมาร์ก experimental ใน docs ที่ bundle มา — ที่เหลือทั้งหมด รวมถึง global-error, instrumentation-client และ metadata file conventions stable แล้วใน Next.js 16.3
ลำดับการซ้อน
หัวข้อที่มีชื่อว่า “ลำดับการซ้อน”component hierarchy ถูกกำหนดตายตัว และมันไม่ใช่ลำดับที่ไฟล์วางอยู่ใน file tree ของ editor — มันคือลำดับที่ React ซ้อนมันรอบ page ของคุณ จากนอกสุดไปในสุด —
flowchart TD Layout["layout.tsx (outermost)"] --> Template["template.tsx"] Template --> Error["error.tsx (error boundary)"] Error --> Loading["loading.tsx (Suspense boundary)"] Loading --> NotFound["not-found.tsx"] NotFound --> Page["page.tsx (innermost)"]
สิ่งนี้ระบุไว้ตรง ๆ ทีละไฟล์ใน docs ที่ bundle มา — layout.js “wraps template.js, error.js, loading.js, not-found.js, and page.js”; template.js “renders between layout.js and error.js… wraps error.js, loading.js, not-found.js, and page.js, but does not wrap the layout.js”; error.js “wraps loading.js, not-found.js, page.js, and nested layout.js files… does not wrap the layout.js or template.js above it”; loading.js “wraps not-found.js, page.js, and nested layout.js files in a <Suspense> boundary… does not wrap the layout.js, template.js, or error.js”; และ page.js “is the innermost file convention” ผลที่ตามมาที่ควรจำไว้ — เพราะ error.js อยู่ ข้างใน layout.js ในลำดับนี้ error ที่ throw จาก layout เองจึงไม่มีวันถูกจับโดย error.tsx ของ segment เดียวกัน — มีแค่ global-error.tsx (หรือ error.tsx ของ parent segment) เท่านั้นที่จับได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”route segment หนึ่งไม่ใช่ “ไฟล์เดียว” — มันคือการซ้อน wrapper component ที่ optional สูงสุดหกชั้นรอบ page ของคุณ ตามลำดับที่ framework บังคับไว้ ไม่ใช่ลำดับที่ file tree ของคุณสื่อ การอ่านโฟลเดอร์คือการถามสำหรับแต่ละชื่อที่จองไว้ที่มีอยู่ — มันอยู่ชั้นไหนของการซ้อนนั้น และมันเข้าไม่ถึงอะไรบ้าง — layout ไม่เคยเห็น error ของ template/error ที่เป็น child ของมันเองข้างบน และ loading boundary ไม่เคยครอบ layout ที่มันแขวนอยู่
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
| ชื่อไฟล์ที่จองไว้แทน router config | file tree คือ routing table เลย ไม่มีอะไรอื่นต้อง sync | ต้องจำว่าชื่อไหนมีความหมาย พิมพ์ผิด (Page.tsx) กลายเป็นไฟล์เฉย ๆ |
| ลำดับการซ้อนตายตัว (layout → template → error → loading → not-found → page) | error/loading boundary ต่อ segment คาดเดาได้ ประกอบกันได้ | error ของ layout เองมองไม่เห็นจาก error.tsx ของ segment ตัวเอง — สมมติผิดง่าย |
global-error.tsx สำหรับ failure ระดับ root | ทางเดียวที่จับ error ที่ throw จาก root layout เองได้ | ต้อง duplicate <html>/<body> เองและใช้ metadata ไม่ได้ |
forbidden.tsx / unauthorized.tsx | UI 403/401 ที่ทำมาเฉพาะ ผูกกับ forbidden()/unauthorized() | ยัง experimental — API อาจเปลี่ยนได้อีก |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
error.tsxจับ error ที่ throw จากlayout.tsxของตัวเองได้” จับไม่ได้ — ในลำดับการซ้อนerror.tsxอยู่ ข้างในlayout.tsxerror จาก render ของ layout เองจึงข้ามมันไป มีแค่global-error.tsx(root) หรือerror.tsxของ parent segment เท่านั้นที่จับ error ของ layout ได้ - “ชื่อไฟล์ที่จองไว้ใช้ได้ทุกที่ ทุกความลึก” บางตัวอยู่ที่ root เท่านั้น (
instrumentation.ts,instrumentation-client.ts) และบางตัวต้องพึ่ง convention อื่นที่ตั้งไว้แล้ว (mdx-components.tsxมีความหมายก็ต่อเมื่อติดตั้ง@next/mdx) - “
forbidden.tsx/unauthorized.tsxstable เท่าerror.tsx” ทั้งคู่ยังมาร์กexperimentalใน docs ที่ bundle มา ต่างจากที่เหลือในตารางนี้ - “โฟลเดอร์ที่มีไฟล์อยู่ข้างในกลายเป็น route อัตโนมัติ” มีแค่
page.tsx(หรือroute.ts) เท่านั้นที่ทำให้ segment เข้าถึงได้ — โฟลเดอร์ที่มีแค่layout.tsxหรือ subfolder จัดระเบียบล้วน ๆ ไม่ตอบสนองต่อ URL เอง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลาโค้ดที่ generate มาอ้างว่าไฟล์ “ทำงานเองได้” เพราะชื่อของมัน เช็คสิ่งเหล่านี้ก่อนเชื่อ —
- คาดหวังให้
error.tsxจับ failure ที่ throw ตรงจากlayout.tsxใน segment เดียวกัน หรือเปล่า? จับไม่ได้ — ต้องใช้global-error.tsxหรือerror.tsxของ parent segment - convention ที่เป็น root-only (
instrumentation.ts,instrumentation-client.ts) ถูกวางไว้ในโฟลเดอร์ route ที่ nest ลึกแทนที่จะเป็น root โปรเจกต์ (หรือsrc/) หรือเปล่า? global-error.tsxข้าม'use client'หรือพยายาม exportmetadataหรือเปล่า? ทั้งสองอย่างจำเป็น/ห้ามตามลำดับ และไฟล์ต้อง render<html>/<body>เองforbidden.tsx/unauthorized.tsxถูกนำเสนอเหมือนเป็น API ที่ stable มานาน ทั้งที่ docs ที่ bundle มายังมาร์กทั้งคู่experimentalหรือเปล่า?route.tsวางอยู่ข้าง ๆpage.tsxใน segment เดียวกัน โดยคาดหวังให้ทั้งคู่ทำงานหรือเปล่า? segment หนึ่งจะ resolve ไปที่อันใดอันหนึ่ง ไม่ใช่ทั้งคู่ สำหรับ path เดียวกัน
🐛 หา bug ในโค้ดนี้
export default function DashboardLayout({ children }: { children: React.ReactNode }) {const config = getDashboardConfig() // throws if the config file is missingreturn ( <section> <nav>{config.title}</nav> {children} </section>)}
// app/dashboard/error.tsx'use client'export default function DashboardError({ error }: { error: Error }) {return <p>Dashboard failed to load: {error.message}</p>}เฉลย
ดูเหมือนเป็นคู่ที่จับกันพอดี แต่ error.tsx อยู่ ข้างใน layout.tsx ในลำดับการซ้อน — มันครอบ page.tsx (และ layout.tsx ที่ nest ต่อ) ไม่ใช่ครอบ layout.tsx ใน segment ของตัวเอง ถ้า getDashboardConfig() throw ตอน DashboardLayout เองกำลัง render app/dashboard/error.tsx จะไม่เห็น error นี้เลย — มันจะลอยขึ้นไปหา error.tsx ของ parent segment ที่ใกล้ที่สุด หรือไปที่ global-error.tsx ของ root ถ้าไม่มี boundary ของ parent เลย ทางแก้คือย้าย call ที่เสี่ยงไปไว้ใน page.tsx (ซึ่ง error.tsx นี้ครอบอยู่จริง) หรือพึ่งพา error boundary ของ parent segment โดยตั้งใจออกแบบให้เป็นแบบนั้น