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

Dynamic Segments และ Route Groups

folder ที่อยู่ใน bracket เปลี่ยนส่วนของ URL ให้เป็น param ที่เราอ่านจาก Promise ส่วน folder ในวงเล็บและที่ขึ้นต้นด้วย underscore จัดโครงสร้าง file tree ใหม่โดยไม่แตะ URL

folder ชื่อ [id] จับ URL ส่วนหนึ่งมาเป็น param ใน Next.js ปัจจุบัน params เป็น Promise เราจึงต้อง await ก่อนใช้ searchParams ก็เป็นแบบเดียวกัน

app/blog/[id]/page.tsx
export default async function Post({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { id } = await params
const { sort } = await searchParams
return <article>Post {id} sorted by {sort}</article>
}

ใช้ [...slug] จับหลาย segment มาเป็น array ใช้ [[...slug]] เพื่อทำให้ segment เป็น optional เพื่อให้ route แม่ match ได้ด้วยแม้ไม่มี segment ต่อท้าย

app/
docs/
[...slug]/page.tsx // matches /docs/a and /docs/a/b/c
shop/
[[...slug]]/page.tsx // matches /shop AND /shop/a/b
app/docs/[...slug]/page.tsx
export default async function Docs({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
return <h1>{slug.join(' / ')}</h1>
}

เมื่อ [[...slug]] match กับ path แม่โดยไม่มี segment ต่อท้ายเลย params.slug จะเป็น undefined — ไม่ใช่ [] build และ request จริงยืนยันแบบนี้

app/shop/[[...slug]]/page.tsx
export default async function Shop({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
return <p>slug: {String(slug)}</p>
}
curl http://localhost:3000/shop
-> slug: undefined

โค้ดที่ทำ slug.length หรือ slug.map(...) โดยไม่เช็ค slug?.length ก่อน จะ throw ตอน runtime พอดีกับ route ที่ optional catch-all ตั้งใจให้ match ด้วย นั่นคือ base path ที่ไม่มี segment ต่อท้าย

ใช้ export generateStaticParams เพื่อบอก Next.js ว่าจะ prerender param ค่าไหนบ้างตอน build แต่ละ object ที่ return จะกลายเป็นหนึ่ง page ที่ generate แบบ static

app/blog/[id]/page.tsx
export function generateStaticParams() {
return [{ id: '1' }, { id: '2' }, { id: '3' }]
}

ถ้าเปิด cacheComponents ไว้ generateStaticParams ต้อง return param อย่างน้อยหนึ่งตัว — การ return array ว่างจะเป็น build error

dynamicParams ควบคุม param ที่ generateStaticParams ไม่ได้ list ไว้ — แต่มันใช้ไม่ได้เลยถ้าเปิด cacheComponents

หัวข้อที่มีชื่อว่า “dynamicParams ควบคุม param ที่ generateStaticParams ไม่ได้ list ไว้ — แต่มันใช้ไม่ได้เลยถ้าเปิด cacheComponents”

dynamicParams (default true) กำหนดว่าจะเกิดอะไรขึ้นเมื่อผู้เข้าชม hit param ค่าที่ generateStaticParams ไม่เคย return: default คือ Next.js จะ render แบบ dynamic ตอน request แรกแล้ว cache ผลไว้ได้ ส่วน dynamicParams = false ทำให้ param ที่ไม่ได้ list ไว้กลาย 404 แทน

// app/blog/[slug]/page.tsx — variant B
export const dynamicParams = false
export function generateStaticParams() {
return [{ slug: 'a' }, { slug: 'b' }]
}

route segment config option นี้เป็นส่วนหนึ่งของ caching model แบบก่อน cacheComponents และมันเข้ากันไม่ได้กับ cacheComponents โดยตรง เปิด cacheComponents: true ใน next.config.ts แล้วยังมี dynamicParams export ค้างอยู่ เป็น build error จริง ไม่ใช่แค่เงียบ ๆ ไม่ทำอะไร

next build (cacheComponents: true, dynamicParams present)
-> Error: Route segment config "dynamicParams" is not compatible with `nextConfig.cacheComponents`. Please remove it.

ภายใต้ cacheComponents การควบคุมแบบเดียวกันนี้เป็นเรื่องโครงสร้างแทนที่จะเป็น config flag: param ที่ list ไว้ใน generateStaticParams ได้ validate ตอน build time และได้ static HTML ส่วน param ที่ไม่ได้ list ไว้กลายเป็น runtime data จริง ๆ ที่ต้องห่อด้วย <Suspense> (ดู section Cache Components ในโมดูล caching) — ไม่มีสวิตช์ dynamicParams = false ให้ param ที่ไม่ได้ list 404 ตรง ๆ อีกแล้ว

folder ในวงเล็บอย่าง (marketing) คือ route group ใช้จัดระเบียบไฟล์และถือ shared layout ของตัวเองได้ แต่ชื่อโฟลเดอร์จะถูกตัดออกจาก URL ส่วน folder ที่ขึ้นต้นด้วย underscore อย่าง _components เป็น private folder ที่ opt out จาก routing ทั้งหมด

app/
(marketing)/
layout.tsx // layout for marketing pages only
about/page.tsx // URL is /about, NOT /marketing/about
(shop)/
cart/page.tsx // URL is /cart
_components/ // private, never a route
Button.tsx

route group ไม่แชร์ layout กันเอง และข้ามระหว่าง root layout บังคับ full reload

หัวข้อที่มีชื่อว่า “route group ไม่แชร์ layout กันเอง และข้ามระหว่าง root layout บังคับ full reload”

แต่ละ route group define root layout ของตัวเองได้ — นี่คือเหตุผลหลักที่ใช้มัน (แยก (marketing) กับ (shop) ให้แต่ละอันมี <html>/<body> และ provider คนละชุดไปเลย) ไม่มีอะไรเชื่อม layout ทั้งสองเข้าด้วยกัน และการ navigate ฝั่ง client จาก route ของ group หนึ่งไปอีก group หนึ่งใช้ state ของ group แรกซ้ำไม่ได้เลย เพราะ tree ทั้งสองไม่มี ancestor ร่วมกันใน app

// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body data-group="marketing">{children}</body>
</html>
)
}
// app/(shop)/layout.tsx
export default function ShopLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body data-group="shop">{children}</body>
</html>
)
}

พิสูจน์ในเบราว์เซอร์จริง: โหลด /about (group (marketing)) ตั้ง marker บน window แล้วคลิก <Link> ไป /cart (group (shop))

before clicking Link -> { group: "marketing", marker: "still-here", loadCount: 1 }
after clicking Link to /cart -> { group: "shop", marker: undefined, loadCount: 1 }

marker หายไปแปลว่า JavaScript execution context ทั้งก้อนถูกทิ้งแล้วเริ่มใหม่ — full page reload — แม้ว่า navigation จะเกิดผ่าน <Link> และ Network tab ยังโชว์การพยายามยิง _rsc fetch ก่อนก็ตาม Next.js พยายาม soft navigation ก่อน พอเจอว่า response เป็นของ root layout คนละตัว มันจะ fallback ไปทำ document reload จริงแทนที่จะพยายาม reconcile สอง tree ที่เข้ากันไม่ได้

graph TD
  A["[id] dynamic segment"] --> U1["changes URL: /blog/42"]
  B["[...slug] catch-all"] --> U2["changes URL: /docs/a/b"]
  C["(marketing) route group"] --> U3["no URL change"]
  D["_components private"] --> U4["not routable"]
  C -->|"different root layout than"| E["(shop) route group"]
  E -->|"Link between them"| F["full page reload, not soft nav"]
folder ไหนเปลี่ยน/ไม่เปลี่ยน URL และตรงไหนที่ reload เกิดขึ้น

มอง bracket, วงเล็บ, และ underscore เป็นเครื่องมือ file-system สามชนิดที่ไม่เกี่ยวกันเลย แค่บังเอิญอยู่ใน folder name เดียวกัน bracket เท่านั้นที่แตะ URL — มันจับข้อมูล วงเล็บกับ underscore แค่จัดระเบียบไฟล์บน disk เท่านั้น route group เพิ่มเติมคือให้ subtree ทั้งก้อน define root layout ของตัวเองได้ และพอมันทำแบบนั้น subtree นั้นก็กลายเป็น document แยกต่างหากในสายตาของ browser ทันที — navigate เข้าหรือออกจากมันไม่มีวันเป็น soft navigation

conventionเปลี่ยน URL ไหมdefine root layout ของตัวเองได้ไหม
[id] / [...slug] / [[...slug]]ใช่ — จับค่าไว้ไม่ได้
(group)ไม่ได้ — และพอทำแบบนั้น การ navigate แบบ soft ไป/กลับ sibling group ที่มี root layout ต่างกันจะหายไป
_privateไม่ — ไม่ routable เลยไม่ได้
dynamicParams (model ก่อน cacheComponents)ไม่เกี่ยวควบคุม 404 กับ runtime-render ของ param ที่ไม่ได้ list ไว้ ใช้ไม่ได้เลยถ้าเปิด cacheComponents
  • “params.slug เป็น [] เมื่อ [[...slug]] match โดยไม่มี segment ต่อท้าย” พิสูจน์แล้วด้านบนว่ามันคือ undefined โค้ดที่สมมติว่าเป็น array จะ crash พอดีกับ base route ที่ optional form ตั้งใจให้ match ด้วย
  • “dynamicParams = false ยังใช้ได้อยู่ถ้าเปิด cacheComponents” มันเป็น build error จริง ไม่ใช่แค่ warning — สอง model นี้ตั้งใจออกแบบให้ใช้ร่วมกันไม่ได้
  • “เป็นการคลิก <Link> ก็ต้อง soft navigation สิ” ไม่จริงถ้าปลายทางมี root layout ต่างกัน — พิสูจน์แล้วด้วย marker บน window ที่หายไป แปลว่าหน้าทั้งหน้า reload จริง

เมื่อโค้ดที่ AI สร้างแตะ dynamic segment หรือ route group ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ

  • optional catch-all handler เรียก slug.length หรือ slug.map(...) โดยไม่เช็ค null ก่อนไหม base route ที่ไม่มี segment ต่อท้ายทำให้ slug เป็น undefined ไม่ใช่ []
  • dynamicParams ถูก export คู่กับ config cacheComponents: true ไหม นั่นคือ build error ไม่ใช่ combination ที่ใช้ได้
  • โค้ดสมมติว่าทุก route group ใต้ app/ แชร์ root layout เดียวกันไหม แต่ละ group ที่มี layout.tsx ระดับบนสุดของตัวเอง จะได้ <html>/<body> คนละชุดที่ไม่เกี่ยวกันเลย
  • มีการอ้างว่า “client-side navigation เร็ว” ระหว่างสอง route group ที่ต่าง define <html>/<body> ไหม การข้ามแบบนั้นเป็น full reload เสมอ พิสูจน์แล้วด้านบน
  • สอง route group ต่าง define page ที่ URL สุดท้ายซ้ำกันไหม (เช่น (marketing)/about/page.tsx กับ (shop)/about/page.tsx) Next.js ถือว่าเป็น path ที่ชนกันและ error

🐛 หา bug ในโค้ดนี้

app/shop/[[...slug]]/page.tsx
export default async function Shop({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
const crumbs = slug.map((s) => s.toUpperCase())
return <p>{crumbs.join(' / ')}</p>
}
เฉลย

โค้ดนี้สมมติว่า slug เป็น array เสมอ พิสูจน์แล้วด้านบนว่าสำหรับ route /shop ที่ไม่มี segment ต่อท้าย ซึ่ง optional catch-all ตั้งใจออกแบบให้ match ด้วยโดยเฉพาะ params.slug จะเป็น undefined — เรียก .map บนมันจะ throw TypeError ตอน request time พอดีกับกรณี “ว่าง” ที่ฟอร์ม [[...]] มีไว้จัดการ วิธีแก้คือ const crumbs = (slug ?? []).map(...)

ใน Next.js ปัจจุบัน อ่าน id จาก dynamic segment อย่างไร
request ที่ /shop พอดี (ไม่มี segment ต่อท้าย) จะเกิดอะไรขึ้น
const { slug } = await params // route is app/shop/[[...slug]]/page.tsx
return <p>{slug.length}</p>
generateStaticParams ใช้ทำอะไร
ถ้า build จะเกิดอะไรขึ้น
// next.config.ts has cacheComponents: true
export const dynamicParams = false
export function generateStaticParams() { return [{ slug: 'a' }] }
route group (marketing) ส่งผลต่อ URL อย่างไร
สอง route group (marketing) กับ (shop) ต่าง define root layout.tsx ของตัวเองพร้อม <html>/<body> คลิก <Link> จากหน้า (marketing) ไปหน้า (shop) เกิดอะไรขึ้น พิสูจน์แล้วในเบราว์เซอร์จริง