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

Dynamic Routes

คุณมี collection ของโพสต์บล็อก 50 อัน และอยากได้หน้าเพจต่ออัน แต่คงไม่นั่งเขียนไฟล์ 50 ไฟล์ dynamic route คือไฟล์เดียวที่ชื่อมีส่วนแปรผันอยู่ในวงเล็บเหลี่ยม และแทน URL ได้หลายอัน:

  • src/pages/blog/[slug].astro → ตรงกับ /blog/anything
  • src/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 ([...path]) รวบทุกอย่างหลังจากมันเป็น string เดียว และ — ต่างจาก param วงเล็บธรรมดา — มันจับคู่กับ ศูนย์ segment ก็ได้ ซึ่งกรณีนั้น Astro.params.path จะเป็น undefined:

src/pages/docs/[...path].astro
---
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>

ใน static build ไฟล์ dynamic route จะ export getStaticPaths() ซึ่งคืน list ของหน้าที่ต้อง generate แต่ละอันคือ { params, props } โดย params เติมส่วนในวงเล็บ และ props ส่งข้อมูลเข้าหน้า:

src/pages/blog/[slug].astro
---
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 ไฟล์"]
getStaticPaths เปลี่ยน entry ให้เป็นหน้า

สำหรับ list ยาว ๆ คุณอยากได้หน้าแบบมีเลข /blog/1, /blog/2 getStaticPaths รับ helper ชื่อ paginate ที่หั่น list เป็นหน้า ๆ และ generate route ให้ ตั้งชื่อไฟล์ route ด้วย param ของหน้า มักจะเป็น src/pages/blog/[page].astro:

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 คำนวณให้:

dist/blog/1/index.html
<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>

มีข้อผิดพลาดที่ 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 ปลอม ๆ

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 version
export 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

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 ที่คืนมาโดยไม่มี params fail ด้วย 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 ล้วนไม่มีมาให้

เมื่อ snippet ที่ generate มานิยาม dynamic route ให้ตรวจสิ่งเหล่านี้ก่อนเชื่อ:

  • route ใต้ [slug].astro/[id].astro (ที่โปรเจกต์ใช้ static output ตามค่าเริ่มต้น) export getStaticPaths() หรือเปล่า? การไม่มีมันไม่ใช่เรื่องจุกจิกของ lint — มันคือ error ที่ทำให้ build พังโดยไม่มี fallback
  • ทุก object ที่ getStaticPaths() คืนมามี key params และชื่อ 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 ในโค้ดนี้

src/pages/blog/[slug].astro
---
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 อีกเลย

getStaticPaths() คืนอะไรสำหรับ static dynamic route?
ใน [slug].astro key params ต้องชื่ออะไร?
helper paginate() ให้อะไรกับแต่ละหน้าที่ generate?
ต้องการ route สำหรับ user id ใด ๆ ที่รู้ไม่ได้ตอน build ทำยังไง?
ไฟล์นี้ไม่มี export getStaticPaths() บน Astro 7.3.5 จะเกิดอะไรขึ้น?
---
// src/pages/blog/[slug].astro — static output, no adapter configured
const posts = await getCollection('blog');
const post = posts.find((p) => p.id === Astro.params.slug);
---
รายการที่สอง (path: undefined) build อะไร?
// src/pages/docs/[...path].astro
export async function getStaticPaths() {
  return [{ params: { path: 'guide/intro' } }, { params: { path: undefined } }];
}
const { path } = Astro.params;