Dynamic Segments และ Route Groups
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”folder ที่อยู่ใน bracket เปลี่ยนส่วนของ URL ให้เป็น param ที่เราอ่านจาก Promise ส่วน folder ในวงเล็บและที่ขึ้นต้นด้วย underscore จัดโครงสร้าง file tree ใหม่โดยไม่แตะ URL
dynamic segment อ่านค่าจาก Promise
หัวข้อที่มีชื่อว่า “dynamic segment อ่านค่าจาก Promise”folder ชื่อ [id] จับ URL ส่วนหนึ่งมาเป็น param ใน Next.js ปัจจุบัน params เป็น Promise เราจึงต้อง await ก่อนใช้ searchParams ก็เป็นแบบเดียวกัน
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>}catch-all และ optional catch-all
หัวข้อที่มีชื่อว่า “catch-all และ optional catch-all”ใช้ [...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/bexport default async function Docs({ params,}: { params: Promise<{ slug: string[] }>}) { const { slug } = await params return <h1>{slug.join(' / ')}</h1>}กรณีว่างของ optional catch-all คือ undefined ไม่ใช่ array ว่าง
หัวข้อที่มีชื่อว่า “กรณีว่างของ optional catch-all คือ undefined ไม่ใช่ array ว่าง”เมื่อ [[...slug]] match กับ path แม่โดยไม่มี segment ต่อท้ายเลย params.slug จะเป็น undefined — ไม่ใช่ [] build และ request จริงยืนยันแบบนี้
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 ต่อท้าย
generateStaticParams prerender dynamic route
หัวข้อที่มีชื่อว่า “generateStaticParams prerender dynamic route”ใช้ export generateStaticParams เพื่อบอก Next.js ว่าจะ prerender param ค่าไหนบ้างตอน build แต่ละ object ที่ return จะกลายเป็นหนึ่ง page ที่ generate แบบ static
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 Bexport 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 ตรง ๆ อีกแล้ว
route group และ private folder
หัวข้อที่มีชื่อว่า “route group และ private folder”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.tsxroute 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.tsxexport default function MarketingLayout({ children,}: { children: React.ReactNode}) { return ( <html lang="en"> <body data-group="marketing">{children}</body> </html> )}// app/(shop)/layout.tsxexport 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"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มอง 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 เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างแตะ dynamic segment หรือ route group ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ
- optional catch-all handler เรียก
slug.lengthหรือslug.map(...)โดยไม่เช็ค null ก่อนไหม base route ที่ไม่มี segment ต่อท้ายทำให้slugเป็นundefinedไม่ใช่[] dynamicParamsถูก export คู่กับ configcacheComponents: 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 ในโค้ดนี้
export default async function Shop({params,}: {params: Promise<{ slug?: string[] }>}) {const { slug } = await paramsconst 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(...)