Dynamic Routes
route ที่มีส่วนแปรผัน
หัวข้อที่มีชื่อว่า “route ที่มีส่วนแปรผัน”คุณมี collection ของโพสต์บล็อก 50 อัน และอยากได้หน้าเพจต่ออัน แต่คงไม่นั่งเขียนไฟล์ 50 ไฟล์ dynamic route คือไฟล์เดียวที่ชื่อมีส่วนแปรผันอยู่ในวงเล็บเหลี่ยม และแทน URL ได้หลายอัน:
src/pages/blog/[slug].astro→ ตรงกับ/blog/anythingsrc/pages/[category]/[id].astro→ สองตัวแปรsrc/pages/docs/[...path].astro→ rest param ตรงกับ/docs/a/b/c(ลึกเท่าไรก็ได้)
ค่าในวงเล็บมาถึงตอน runtime บน Astro.params (Astro.params.slug) แต่สำหรับเว็บ static Astro ต้องรู้ด้วยว่า มี slug ไหนบ้าง เพื่อ build ไฟล์ให้แต่ละอัน นั่นคือหน้าที่ของ getStaticPaths()
rest param ใช้งานจริง
หัวข้อที่มีชื่อว่า “rest param ใช้งานจริง”rest param ([...path]) รวบทุกอย่างหลังจากมันเป็น string เดียว และ — ต่างจาก param วงเล็บธรรมดา — มันจับคู่กับ ศูนย์ segment ก็ได้ ซึ่งกรณีนั้น Astro.params.path จะเป็น undefined:
---export async function getStaticPaths() { return [ { params: { path: 'guide/intro' } }, { params: { path: 'guide/advanced/setup' } }, { params: { path: undefined } }, // matches /docs itself ];}const { path } = Astro.params;---<p>path: {path ?? '(root)'}</p>พิสูจน์แล้วในโพรบ — ไฟล์เดียว build route ได้ทุกความลึก รวมถึง root /docs เปลือย ๆ:
generating static routes ├─ /docs/guide/intro/index.html ├─ /docs/guide/advanced/setup/index.html ├─ /docs/index.html$ cat dist/docs/index.html | grep -o '<p>.*</p>'<p>path: (root)</p>getStaticPaths(): หนึ่งหน้าต่อหนึ่ง entry
หัวข้อที่มีชื่อว่า “getStaticPaths(): หนึ่งหน้าต่อหนึ่ง entry”ใน static build ไฟล์ dynamic route จะ export getStaticPaths() ซึ่งคืน list ของหน้าที่ต้อง generate แต่ละอันคือ { params, props } โดย params เติมส่วนในวงเล็บ และ props ส่งข้อมูลเข้าหน้า:
---import { getCollection, render } from 'astro:content';
export async function getStaticPaths() { const posts = await getCollection('blog'); return posts.map((post) => ({ params: { slug: post.id }, // fills [slug] — one page per post props: { post }, // hand the whole entry to the page }));}
const { post } = Astro.props;const { Content } = await render(post);---<article> <h1>{post.data.title}</h1> <time>{post.data.pubDate.toDateString()}</time> <Content /></article>ตอน build Astro เรียก getStaticPaths() เห็น (สมมติ) 50 entry แล้วเขียนไฟล์ HTML 50 ไฟล์ ทั้ง /blog/hello-world, /blog/second-post เรื่อยไป key params ต้อง ตรงกับชื่อในวงเล็บ (ตรงนี้คือ slug) ส่วน props คือทางลัดที่ส่ง entry ที่โหลดมาแล้วเข้าไป หน้า body จะได้ไม่ต้อง query ซ้ำ
flowchart LR
coll["getCollection('blog') — 50 entry"] --> map["map แต่ละอันเป็น params + props"]
map --> paths["getStaticPaths คืน 50 path"]
paths --> build["build เขียนไฟล์ HTML 50 ไฟล์"]pagination ด้วย paginate()
หัวข้อที่มีชื่อว่า “pagination ด้วย paginate()”สำหรับ list ยาว ๆ คุณอยากได้หน้าแบบมีเลข /blog/1, /blog/2 getStaticPaths รับ helper ชื่อ paginate ที่หั่น list เป็นหน้า ๆ และ generate route ให้ ตั้งชื่อไฟล์ route ด้วย param ของหน้า มักจะเป็น src/pages/blog/[page].astro:
---import type { GetStaticPathsOptions } from 'astro';import { getCollection } from 'astro:content';
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) { const posts = await getCollection('blog'); return paginate(posts, { pageSize: 10 }); // 10 posts per page}
const { page } = Astro.props; // paginate injects a `page` prop---<ul> {page.data.map((post) => <li>{post.data.title}</li>)}</ul><nav> {page.url.prev && <a href={page.url.prev}>Previous</a>} <span>Page {page.currentPage} of {page.lastPage}</span> {page.url.next && <a href={page.url.next}>Next</a>}</nav>paginate ส่ง prop page ให้แต่ละหน้า มีทั้ง data (slice ของหน้านี้), currentPage, lastPage, และ url.prev/url.next ครบทุกอย่างที่ต้องใช้ทั้ง list และลิงก์ prev/next
พิสูจน์แล้วในโพรบ — โพสต์สี่อันที่ pageSize: 2 build ได้พอดีสองหน้าแบบมีเลข และ <nav> render ลิงก์ prev/next จริงที่ paginate คำนวณให้:
<nav><span>Page 1 of 2</span><a href="/blog/2">Next</a></nav><!-- real build output: dist/blog/2/index.html --><nav><a href="/blog/1">Previous</a><span>Page 2 of 2</span></nav>เมื่อ getStaticPaths() หายไปหรือคืนค่าผิด
หัวข้อที่มีชื่อว่า “เมื่อ getStaticPaths() หายไปหรือคืนค่าผิด”มีข้อผิดพลาดที่ block build สองแบบที่พบบ่อยพอจะจำข้อความจริงของมันได้เลย แบบแรก dynamic route แบบ static ที่ไม่มี getStaticPaths เลย — Astro ไม่เดาให้ มัน fail build ด้วย error ที่มีชื่อชัดเจน:
[GetStaticPathsRequired] `getStaticPaths()` function is required for dynamic routes.Make sure that you `export` a `getStaticPaths()` function from your dynamic route. Hint: If you meant for this route to be server-rendered, set `export const prerender = false;` in the page.แบบที่สอง getStaticPaths() ที่รันแต่คืนค่าที่ผิดรูปแบบ — ตรงนี้คือ object ที่ขาด key params ที่จำเป็น:
export async function getStaticPaths() { return [{ id: 'one' }]; // missing `params` — invalid}[GetStaticPathsExpectedParams] Missing or empty required `params` property on `getStaticPaths()` route.ทั้งสองพิสูจน์แล้วในโพรบบน Astro 7.3.5 และทั้งคู่ระบุ contract ที่ getStaticPaths() ต้องทำตามชัดเจน — hint ของ error แรกมักเป็นทางแก้อยู่แล้ว: ถ้า route นั้นตั้งใจให้เป็น on-demand จริง ๆ ให้เติม prerender = false แล้วเอา getStaticPaths ออกไปเลย แทนที่จะพยายามทำให้มันผ่านด้วย list ปลอม ๆ
static param vs on-demand param
หัวข้อที่มีชื่อว่า “static param vs on-demand param”dynamic route รู้ param ของตัวเองได้สองทาง และสะท้อน rendering mode สองแบบ:
- Static: คุณระบุทุก param ล่วงหน้าด้วย
getStaticPaths()แล้ว Astro build ไฟล์ต่อ path เร็ว, cache ได้ แต่ชุดหน้าถูกล็อกตอน build - On-demand: ตั้ง
export const prerender = falseแล้ว เอาgetStaticPaths()ออก แทนที่ด้วยการอ่านAstro.paramsสด ๆ แล้ว query ทันที วิธีนี้รองรับ URL ที่รู้ตอน build ไม่ได้ (user id, คำค้น):
---// src/pages/blog/[slug].astro — on-demand versionexport const prerender = false;import { getEntry, render } from 'astro:content';
const { slug } = Astro.params;if (!slug) return Astro.redirect('/404');const post = await getEntry('blog', slug);if (!post) return Astro.redirect('/404');
const { Content } = await render(post);---<h1>{post.data.title}</h1><Content />หลักคิด: ชุดหน้าที่ รู้แน่และจำกัด (โพสต์บล็อกทั้งหมด) → getStaticPaths() แล้วอยู่แบบ static ส่วนชุดที่ เปิดกว้าง (profile ของใครก็ได้, ค้นหาอะไรก็ได้) → prerender = false แล้วอ่าน Astro.params ต่อ request
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”getStaticPaths() ไม่ใช่ hook ที่ Astro บังเอิญเรียก — บน static build มันคือ contract ทั้งหมด ที่ไฟล์ dynamic route ต้องทำตาม และถูกตรวจก่อน component script จะรันสักบรรทัด นั่นคือเหตุผลที่ถ้าไม่มีมัน build จะ fail ด้วย error ที่มีชื่อชัดเจน ไม่ใช่ได้หน้าว่าง ๆ: Astro build “บางหน้า” ของ route ที่มันไม่รู้รูปร่างไม่ได้ จึงปฏิเสธที่จะ build เลย การสลับไป prerender = false ไม่ได้ผ่อน contract นั้น — มันแทนที่ด้วย contract อีกแบบ (อ่าน Astro.params ต่อ request) นี่คือเหตุผลที่สองวิธีนี้ใช้แทนกัน ไม่ใช่ซ้อนกัน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
getStaticPaths() (static) |
ทุกหน้าเป็นไฟล์ธรรมดา — เร็ว, cache บน CDN ได้, ไม่ต้องมี server ตอน request | ชุดหน้าทั้งหมดต้องรู้ได้ตอน build; เพิ่มหน้าใหม่ = build ใหม่ |
paginate() |
หน้าแบบมีเลข, ลิงก์ prev/next และ logic การหั่นถูก generate ให้ | ผูกชื่อไฟล์ route กับ param ของหน้า ([page].astro) และผูกแหล่งข้อมูลกับการเรียก paginate() ครั้งเดียว |
rest param [...path] |
ไฟล์เดียวรับได้ทุกความลึก รวมถึง route แม่เปล่า ๆ (path เป็น undefined) |
ทุกจุดที่ใช้ Astro.params.path ต้องรับมือกรณี undefined เอง |
prerender = false + Astro.params |
รองรับ URL ที่ยังไม่มีตอน build | ต้องมี adapter; ทุก request กลายเป็น server round-trip แทนที่จะโดน CDN |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “dynamic route ที่ไม่มี getStaticPaths ก็แค่ build ได้หน้าว่าง” มันไม่ build เลย — Astro ทำให้ทั้ง build fail ด้วย
GetStaticPathsRequiredพร้อมระบุชื่อไฟล์ นี่ตั้งใจ: เว็บ static มี route ที่ชุดหน้าไม่นิยามไม่ได้ - “getStaticPaths คืนอะไรก็ได้ ขอแค่หน้าอ่าน Astro.params ถูก” รูปแบบค่าที่คืนถูกตรวจด้วย — object ที่คืนมาโดยไม่มี
paramsfail ด้วยGetStaticPathsExpectedParamsไม่ว่า script ของหน้าจะรับมือได้หรือไม่ก็ตาม - “rest param
[...path]มีค่าเสมอ” มันจับคู่ศูนย์ segment ได้ด้วย (path แม่ของ route เอง) และAstro.params.pathเป็นundefinedในกรณีนั้น — โค้ดที่คิดว่าเป็น string แล้วเรียก.split('/')ตรง ๆ จะพังที่ route นั้นพอดี - “เอา getStaticPaths ออกแล้วใส่ prerender = false ก็พอสำหรับแก้ error ‘GetStaticPathsRequired’” มันไม่ใช่ patch แต่เป็น rendering mode คนละแบบเลย — ต้องมี adapter ที่ตั้งค่าไว้สำหรับ on-demand rendering ด้วย ซึ่งโปรเจกต์ static ล้วนไม่มีมาให้
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ snippet ที่ generate มานิยาม dynamic route ให้ตรวจสิ่งเหล่านี้ก่อนเชื่อ:
- route ใต้
[slug].astro/[id].astro(ที่โปรเจกต์ใช้ static output ตามค่าเริ่มต้น) exportgetStaticPaths()หรือเปล่า? การไม่มีมันไม่ใช่เรื่องจุกจิกของ lint — มันคือ error ที่ทำให้ build พังโดยไม่มี fallback - ทุก object ที่
getStaticPaths()คืนมามี keyparamsและชื่อ key นั้นตรงกับชื่อในวงเล็บของไฟล์หรือไม่? - โค้ดที่อ่านค่า rest param (
Astro.params.path) คิดว่ามันเป็น string ที่ไม่ว่างเสมอ โดยไม่รับมือ path แม่ของ route ที่ค่าเป็นundefinedหรือเปล่า? - ถ้า route ตั้ง
prerender = falseไฟล์เดียวกันยังเก็บgetStaticPaths()เก่าจากตอนที่เป็น static ไว้ไหม? มันถูกเมินเฉยเมื่อหน้าเป็น on-demand แล้ว — ควรลบทิ้ง ไม่ใช่ปล่อยเป็น dead code ที่ดูเหมือนสำคัญ paginate(list, { pageSize })ถูกเรียกด้วย list เต็มที่ยังไม่กรอง หรือมี.filter()/.slice()ก่อนหน้าที่ตัด list ไปแล้วจนจำนวนหน้าเปลี่ยนจากที่ผู้อ่านคาดโดยไม่รู้ตัว?
🐛 หา bug ในโค้ดนี้
---import { getCollection, render } from 'astro:content';
const posts = await getCollection('blog');const post = posts.find((p) => p.id === Astro.params.slug);if (!post) throw new Error('Post not found');
const { Content } = await render(post);---<h1>{post.data.title}</h1><Content />เฉลย
ไฟล์นี้ไม่มี export getStaticPaths() เลย แต่ output ของโปรเจกต์คือ static ตามค่าเริ่มต้น และ route มี segment ในวงเล็บ ([slug]) Astro ตรวจ contract นี้ก่อนรัน component script ใด ๆ — มันไม่ลองไฟล์แล้ว fail แบบนุ่มนวล แต่ปฏิเสธที่จะ build พิสูจน์แล้วในโพรบบน Astro 7.3.5: build หยุดทันทีด้วย [GetStaticPathsRequired] getStaticPaths() function is required for dynamic routes พร้อมระบุไฟล์นี้ และ hint ของมันบอกสิ่งที่ขาดตรง ๆ: เพิ่ม getStaticPaths() ที่คืน { params: { slug } } สำหรับทุกโพสต์ หรือ — ถ้าตั้งใจให้ค้นหาต่อ request จริง ๆ — เพิ่ม export const prerender = false; ให้ route เป็น on-demand แล้วไม่ต้องมี getStaticPaths อีกเลย