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

Parallel และ Intercepting Routes

parallel routes ให้ layout เดียว render หลาย slot อิสระพร้อมกัน ส่วน intercepting routes ให้ route หนึ่ง render ภายใน layout ปัจจุบันแทนที่จะเปิดเป็นหน้าของตัวเอง

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.tsx
app/dashboard/layout.tsx
export 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 เป็น subtree ของตัวเอง มันจึง define loading.tsx และ error.tsx ของตัวเองได้ fetch ที่ช้าใน @team โชว์ fallback ของตัวเองโดยไม่บล็อก @analytics และ crash ใน slot หนึ่งก็ถูกจับโดย boundary ของ slot นั้นเองโดยไม่ทำให้ตัวอื่นล่มตามไปด้วย

// app/dashboard/@team/loading.tsx
export 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.tsx
export 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.tsx
export default function AnalyticsDefault() {
return null
}
app/dashboard/default.tsx
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.tsx
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <dialog open>MODAL photo {id}</dialog>
}
app/photo/[id]/page.tsx
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-nav
feed
open photo 1
MODAL 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"]
parallel slot, fallback ต่อ slot, และ (.) กับ (..)

มีกลไกอิสระสองอย่างทำงานอยู่ และง่ายมากที่จะปนกัน 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 เมื่อใช้ผิด
@slotrender เนื้อหาอิสระคู่กับ children ใน layout เดียวลืม default.tsx ของ slot เสี่ยง 404 ตอน hard navigation
loading.tsx/error.tsx ต่อ slotstream และแยก 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 ใต้ @modal compile ผ่านไม่มี error แต่ไม่เคย intercept เพราะ folder @slot ไม่นับเป็น level เลย มีแค่ (..) เท่านั้นที่เข้าถึง sibling จริงของ feed
  • “ถ้า intercepting route ผิด build จะบอกเราเอง” ไม่จริง — intercept ที่ level ผิดเป็น routing bug แบบเงียบ เห็นได้ก็ต่อเมื่อคลิกผ่านจริงในเบราว์เซอร์เท่านั้น

เมื่อโค้ดที่ 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 ที่ช้าถูกวางไว้ใน children page ที่ใช้ร่วมกันแทนที่จะเป็น 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.tsx
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <dialog open>Photo {id}</dialog>
}
// app/gallery/page.tsx
import 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 โดยไม่ต้องแก้อะไรอื่นเลย

folder ที่ขึ้นต้นด้วย @ อย่าง @team คืออะไร
ทำไมต้องใส่ default.tsx ให้ parallel route slot
parallel slot แต่ละตัว (เช่น @team กับ @analytics) define loading.tsx และ error.tsx ของตัวเองได้ไหม
build จริงแล้วคลิกผ่านในเบราว์เซอร์ด้วย <Link> จะเกิดอะไรขึ้น
app/feed/@modal/(.)photo/[id]/page.tsx
intercepting convention (..) เจาะไปที่ไหน
hard reload ของ URL ที่ intercept ไว้ (เช่น /photo/1 ไม่ได้คลิกผ่าน Link) โชว์อะไร พิสูจน์แล้วในเบราว์เซอร์จริง