Parallel และ Intercepting Routes
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”parallel routes ให้ layout เดียว render หลาย slot อิสระพร้อมกัน ส่วน intercepting routes ให้ route หนึ่ง render ภายใน layout ปัจจุบันแทนที่จะเปิดเป็นหน้าของตัวเอง
parallel route ด้วย named slot
หัวข้อที่มีชื่อว่า “parallel route ด้วย named slot”folder ที่ขึ้นต้นด้วย @ อย่าง @team หรือ @analytics คือ named slot layout ใน folder เดียวกันรับแต่ละ slot มาเป็น prop และ render พร้อมกันได้ slot ไม่ใช่ URL segment — app/@analytics/views ก็ยัง resolve เป็น URL /views ไม่ใช่ /@analytics/views — มันแค่รวมกับ page.tsx จริงเพื่อประกอบเป็นหน้าของ route หนึ่ง ๆ
app/ dashboard/ layout.tsx page.tsx @team/ page.tsx @analytics/ page.tsxexport default function Layout({ children, team, analytics,}: { children: React.ReactNode team: React.ReactNode analytics: React.ReactNode}) { return ( <section> {children} <div className="grid"> {team} {analytics} </div> </section> )}แต่ละ slot stream อิสระ มี loading.tsx และ error.tsx ของตัวเอง
หัวข้อที่มีชื่อว่า “แต่ละ slot stream อิสระ มี loading.tsx และ error.tsx ของตัวเอง”เพราะแต่ละ slot เป็น subtree ของตัวเอง มันจึง define loading.tsx และ error.tsx ของตัวเองได้ fetch ที่ช้าใน @team โชว์ fallback ของตัวเองโดยไม่บล็อก @analytics และ crash ใน slot หนึ่งก็ถูกจับโดย boundary ของ slot นั้นเองโดยไม่ทำให้ตัวอื่นล่มตามไปด้วย
// app/dashboard/@team/loading.tsxexport default function TeamLoading() { return <p>Loading team…</p>}// app/dashboard/@team/error.tsx'use client'
export default function TeamError({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <div> <h2>Team slot failed to load</h2> <button onClick={() => retry()}>Try again</button> </div> )}default.tsx คือ fallback ตอน hard navigation — ไม่มี default.tsx อาจเป็น 404 จริง
หัวข้อที่มีชื่อว่า “default.tsx คือ fallback ตอน hard navigation — ไม่มี default.tsx อาจเป็น 404 จริง”Next.js ติดตาม subpage ที่ active ของแต่ละ slot ระหว่าง client-side navigation (soft) แม้แต่ slot ที่ URL ไม่ match กับ route ปัจจุบัน แต่ตอน hard navigation (request ใหม่, reload, หรือเข้าลิงก์ตรง ๆ) ไม่มี client state ให้กู้คืน Next.js จึง fallback ไปที่ default.tsx ของแต่ละ slot ถ้าไม่มี slot นั้นจะ render notFound()
// app/dashboard/@team/settings/page.tsxexport const dynamic = 'force-dynamic'
export default function TeamSettingsPage() { return <p>team slot: settings</p>}@team มี subpage /settings จริง แต่ @analytics กับ slot children ที่แฝงอยู่ไม่มี page ที่ match และไม่มี default.tsx เลย route ที่เป็น dynamic จริง ๆ (request ตรง ๆ ไม่มี client-side state มาก่อน) พิสูจน์ 404 ได้จริง
GET /dashboard/settings (no default.tsx anywhere, route forced dynamic) -> HTTP 404เพิ่ม default.tsx ที่ return null ให้ทุก slot ที่ไม่ match แก้ได้
// app/dashboard/@analytics/default.tsxexport default function AnalyticsDefault() { return null}export default function DashboardChildrenDefault() { return null}GET /dashboard/settings (default.tsx added for @analytics and children) -> HTTP 200มีข้อควรระวังหนึ่งที่พิสูจน์แล้วแบบยาก ๆ: ถ้าไม่มีอะไรใน route tree นั้นใช้ dynamic API เลย Next.js สามารถ prerender unmatched-slot combination เป็น static page ตอน build ได้เต็ม ๆ กรณีนั้น path ที่ไม่มี default.tsx ก็ยัง response 200 จาก static cache ได้ (x-nextjs-prerender: 1) เพราะมันคือ page ที่ cache ไว้จริง ไม่ใช่ live 404 กฎ “ไม่มี default.tsx → 404” พูดถึงพฤติกรรมของ request ที่เป็น dynamic จริง ๆ ไม่ได้ cache ไว้ — เช็คด้วย export const dynamic = 'force-dynamic' (หรือ dynamic API จริง ๆ) ใน route ที่กำลังทดสอบก่อนเชื่อผล 200/404 เสมอ
intercepting route: (.) level เดียวกัน, (..) ขึ้นไปหนึ่ง level — folder @slot ไม่นับ
หัวข้อที่มีชื่อว่า “intercepting route: (.) level เดียวกัน, (..) ขึ้นไปหนึ่ง level — folder @slot ไม่นับ”intercepting convention render route ภายใน layout ปัจจุบันโดยที่ URL ยัง update ตัว matcher นับจาก segment level ไม่ใช่ folder บน file-system — folder @slot มองไม่เห็นเลยตอนนับ (.) match level เดียวกัน (..) ขึ้นไปหนึ่ง level (..)(..) ขึ้นไปสอง level และ (...) จาก app root
ตัวอย่างคลาสสิก: photo modal คลิกรูปใน feed แล้วเปิดเป็น modal ทับ feed แต่เข้า URL เดียวกันตรง ๆ จะ render หน้าเต็มแบบ standalone
app/ feed/ page.tsx layout.tsx @modal/ default.tsx (..)photo/ [id]/page.tsx // intercepts /photo/[id] as a modal photo/ [id]/page.tsx // the full standalone page@modal ไม่นับเป็น level เลยตำแหน่งจริงของ folder intercepting นี้อยู่หนึ่ง level ใต้ feed — และ photo เป็น sibling ของ feed ซึ่งอยู่สูงกว่าตำแหน่งนั้นหนึ่ง level พอดี ตรงกับที่ (..) เข้าถึงได้ นี่คือสิ่งที่พิสูจน์แล้ว ไม่ใช่เดา: เขียน (.)photo แทน (ความผิดพลาดที่ดูสมเหตุสมผล — “อยู่ใต้ feed ลงมาสอง folder ก็น่าจะ… level เดียวกันสิ”) build ผ่านไม่มี error เลย แต่ไม่เคย intercept เงียบ ๆ
next build with app/feed/@modal/(.)photo/[id]/page.tsx-> Route (app) lists "ƒ /feed/(.)photo/[id]" — compiles fine
clicking <Link href="/photo/1"> from /feed-> renders "FULL PAGE photo 1" — the modal never appeared, no error eitherเปลี่ยนแค่ชื่อ folder จาก (.)photo เป็น (..)photo (เนื้อไฟล์เดิมทุกอย่าง) คือสิ่งเดียวที่ต้องแก้
// app/feed/@modal/(..)photo/[id]/page.tsxexport default async function PhotoModal({ params,}: { params: Promise<{ id: string }>}) { const { id } = await params return <dialog open>MODAL photo {id}</dialog>}export default async function PhotoFullPage({ params,}: { params: Promise<{ id: string }>}) { const { id } = await params return <article>FULL PAGE photo {id}</article>}พิสูจน์ในเบราว์เซอร์จริง คลิกเดิมตอนนี้โชว์ modal แล้ว URL ก็ยัง update เป็น /photo/1
document.body.innerText after clicking <Link href="/photo/1"> from /feed ->"root-navfeedopen photo 1MODAL photo 1"แต่ hard navigation ไป URL เดียวกัน — เข้าตรง ๆ หรือ reload — ไม่ผ่าน interception เลย เพราะไม่มี client router state ก่อนหน้าให้ intercept จาก
curl http://localhost:3000/photo/1 (direct request, no client router) ->"FULL PAGE photo 1"
reloading /photo/1 in the browser (page.goto, not a Link click) ->document.body.innerText -> "root-nav\nFULL PAGE photo 1"graph TD L["dashboard/layout.tsx"] --> C["children (page.tsx)"] L --> T["@team slot (own loading/error)"] L --> A["@analytics slot (own loading/error)"] T -->|"hard nav, no default.tsx"| NF["notFound() -> 404"] F["click Link in feed"] -->|"(..)photo intercepts"| M["modal, URL updates"] F -->|"(.)photo — wrong level"| WRONG["compiles, never intercepts"] DIRECT["visit /photo/1 directly or reload"] --> FULL["full standalone page"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มีกลไกอิสระสองอย่างทำงานอยู่ และง่ายมากที่จะปนกัน parallel route (@slot) พูดถึง อะไร render ด้วยกัน ใน layout เดียว ส่วน slot จะโชว์เนื้อหาที่ match, fallback, หรือ 404 ขึ้นกับว่า navigation ปัจจุบันเป็น soft (มี client router state) หรือ hard (ไม่มีอะไรให้กู้คืน default.tsx หรือ 404 เป็นคนตัดสิน) ส่วน intercepting route ((.)/(..)/(...)) พูดถึง ไฟล์ไหนตอบ URL ไหน ตอน soft navigation โดยเฉพาะ — นับ level ผิดแล้ว Next.js ก็ยังสร้าง route ที่ valid แต่เข้าถึงไม่ได้เงียบ ๆ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| convention | จุดประสงค์ | รูปแบบ failure เมื่อใช้ผิด |
|---|---|---|
@slot | render เนื้อหาอิสระคู่กับ children ใน layout เดียว | ลืม default.tsx ของ slot เสี่ยง 404 ตอน hard navigation |
loading.tsx/error.tsx ต่อ slot | stream และแยก error อิสระต่อ slot | ถ้าไม่มี fetch ช้าหรือ crash ของ slot หนึ่งกระทบเรื่อง fallback ของทั้ง layout ได้ |
(.) / (..) / (...) | render route ภายใน layout ปัจจุบันตอน soft navigation | นับ level ผิด build ผ่านปกติแต่ไม่เคย intercept — เป็น bug เงียบ ไม่ใช่ build error |
| hard navigation ตรง ๆ ไปยัง URL ที่ intercept ไว้ | render หน้า standalone จริงเสมอ | ไม่มี — นี่คือพฤติกรรมที่ตั้งใจและพิสูจน์แล้ว |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ไม่มี default.tsx เท่ากับ 404 เสมอไม่ว่า request แบบไหน” พิสูจน์แล้วว่ามีข้อยกเว้น: มัน 404 สำหรับ request ที่เป็น dynamic จริง ๆ ไม่ได้ cache ไว้ — แต่ถ้าไม่มีอะไรใน tree เป็น dynamic เลย Next.js อาจ prerender combination ที่ไม่ match นั้นเป็น static 200 ตอน build ได้
- ”(.) กับ (..) ใช้แทนกันได้เกือบหมด — เลือกอันที่ ‘รู้สึก’ ตรงกับความลึกของ folder” พิสูจน์แล้วว่าผิด:
(.)photoใต้@modalcompile ผ่านไม่มี error แต่ไม่เคย intercept เพราะ folder@slotไม่นับเป็น level เลย มีแค่(..)เท่านั้นที่เข้าถึง sibling จริงของfeed - “ถ้า intercepting route ผิด build จะบอกเราเอง” ไม่จริง — intercept ที่ level ผิดเป็น routing bug แบบเงียบ เห็นได้ก็ต่อเมื่อคลิกผ่านจริงในเบราว์เซอร์เท่านั้น
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างใช้ parallel หรือ intercepting route ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ
- ทุก slot ที่อาจไม่ match มี
default.tsxไหม ถ้าไม่มี hard navigation ไปสถานะนั้นอาจเป็น 404 จริง - การนับ intercepting level นับจากความลึกของ file-system แทนที่จะเป็น segment depth ไหม folder
@slotใด ๆ ใน path ต้องถูกข้ามตอนนับ(.)/(..)/(...) - โค้ดสมมติว่า build ผ่านแปลว่า interception ทำงานไหม มันไม่จับ level ที่ผิด — เห็นได้ก็ต่อเมื่อคลิกลิงก์จริงเท่านั้น
- data fetch ที่ช้าถูกวางไว้ใน
childrenpage ที่ใช้ร่วมกันแทนที่จะเป็น slot ของตัวเองไหม แบบนั้นบล็อก initial paint ของทั้ง layout แทนที่จะให้ slot อิสระ stream ของตัวเอง - test หรือ demo เช็คแค่ URL แบบ direct/hard navigation ของ intercepted route ไหม แบบนั้น design มาให้โชว์หน้าเต็มเสมอ — interception เกิดผ่าน soft client-side navigation เท่านั้น
🐛 หา bug ในโค้ดนี้
// app/gallery/@modal/(.)photo/[id]/page.tsxexport default async function PhotoModal({params,}: {params: Promise<{ id: string }>}) {const { id } = await paramsreturn <dialog open>Photo {id}</dialog>}
// app/gallery/page.tsximport Link from 'next/link'export default function Gallery() {return <Link href="/photo/1">Open photo 1</Link>}เฉลย
@modal เป็น slot ไม่ใช่ segment เลยไม่นับเข้า intercepting level ตำแหน่งจริงของ folder intercepting อยู่หนึ่ง level ใต้ gallery — และ photo อยู่เป็น sibling ของ gallery ซึ่งสูงกว่าตำแหน่งนั้นหนึ่ง level (.) (level เดียวกัน) ตื้นเกินไปหนึ่ง level มีแค่ (..) เท่านั้นที่เข้าถึงมันได้ พิสูจน์ตรง ๆ แล้ว: โครงสร้างเดียวกันเป๊ะนี้ด้วย (.) compile ผ่านไม่มี error และ list route จริงใน build output แต่คลิก <Link> แล้ว render หน้า standalone /photo/[id] เต็ม ๆ แทน modal — พลาดเงียบ ๆ ไม่ใช่ crash วิธีแก้คือเปลี่ยนชื่อ folder จาก (.)photo เป็น (..)photo โดยไม่ต้องแก้อะไรอื่นเลย