iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 15

同一個 [slug].astro,為什麼有時候 build 時就算好、有時候有人開才算?

  • 分享至 

  • xImage
  •  

前一篇用 getCollection() 把全部文章撈出來,做了 /blog 列表和一個純前端的搜尋 island。從列表點進單篇文章時,對應的檔案是 blog/[...slug].astro,也就是一個帶方括號的檔案。這篇回答兩件事:一個檔怎麼對應到幾十個網址,以及這些網址是在 build 時先產好,還是有人開才即時算。

我常看到從 WordPress 轉到 Astro 的人,最不習慣的是這件事:WordPress 預設在有人打開頁面時,才由伺服器當場組出內容;Astro 則預設在 build 時先算好,上線後直接送出已產好的檔案。SSR、SSG 在搬遷時反覆出現,因為這是整個搬遷裡最大的轉變。但再問下去,有人說要 SSR、有人說要 SSG,理由卻只剩「剛從 WordPress 換過來」,沒說清楚網站需要哪一種,也沒說清兩個詞各自代表什麼。這時候的 SSR、SSG 比較像搬遷時的身份標籤,還不是技術決定。

兩種機制的差別講清楚之後,選哪一邊就只是照條件對。

名冊還是現場

一個檔名帶方括號的頁,像 [slug].astro,叫動態路由:一個檔案對應多個網址,方括號裡就是會變的那一段。blog/[...slug].astro 一個檔,服務 /blog/day-01/blog/day-02⋯⋯幾十個網址。這件事本身很單純。

這些網址「什麼時候被算出來」則有兩條路,而且同一個檔就能切換。我用「名冊」和「現場」來記這兩條路:

  • 名冊(SSG,build 時就算好):開場前,先把所有客人的名字印在一張名冊上。build 的時候,Astro 就把每個網址的 HTML 都產好,上線後直接發檔案。你得先給它一份完整名單,它才知道要印哪些頁,而這份名單就是 getStaticPaths()
  • 現場(SSR / on-demand,有人開才算):不先印名冊,等有人走到門口,才當場問「你是誰」。有人開這個網址,伺服器才即時算一份給他。好處是它能拿到當下才知道的東西:誰登入了、現在幾點、網址帶了什麼參數。

這幾個詞後面會反覆出現,先各給一句定義:

  • SSG(靜態產生):build 時把每頁 HTML 都先產好,最快。=名冊。
  • SSR / on-demand(伺服器即時渲染):有人請求才當場算。=現場。
  • getStaticPaths():SSG 動態路由專用,你在這個函式裡「列出所有網址」。=印名冊的動作。
  • prerender:頁面層級的開關,決定「這一頁」走名冊還是現場。
  • output:整個專案的預設,決定這個開關預設朝哪邊。

決定頁面何時產生的兩個設定

同一個 [slug].astro 是在 build 時算好,還是等有人開才算,決定因素只有兩個:專案的 output,加上這一頁的 prerender。跟你裝了哪個 adapter 無關。

  • output: 'static'(本專案的預設,因為 astro.config.mjs 沒有設定 output)→ 頁面預設走名冊,動態路由必須有 getStaticPaths() 把所有網址先列出來。
  • 要讓某一頁改走現場 → 在那頁加一行 export const prerender = false。adapter 會提供伺服器 runtime,讓它即時算。

adapter 在需要現場渲染時提供伺服器 runtime,不會把專案的預設從名冊改成現場。

這個 repo 裡就有一個我寫錯的例子。寫這篇前重看 src/pages/blog/[...slug].astro,才發現自己幾天前寫的一行註解錯了:

// Content-first:文章頁在 build 時預渲染(adapter 預設 output:'server',這裡逐頁 opt-in SSG)。
export const prerender = true;

「adapter 預設 output:'server'」這句是錯的。裝了 @astrojs/cloudflare 不代表 output 就變成 server。跑一次 npm run build,log 第一行就打臉:

[build] output: "static"

專案是 static。那行 prerender = true 其實是多餘的(static 本來就會走名冊),而註解的前提整個站不住。已經改掉了。

我已經記不清當時為什麼這樣寫,大概是憑印象、沒回頭查。這種前提寫錯不會噴錯、不會壞頁面,所以會一路留著;npm run build 的第一行就能對答案。

同一段 build log 還有一個容易混淆的細節:除了 output: "static",它也印出一行 mode: "server"

[build] output: "static"
[build] mode: "server"

mode: "server" 指的是:站上有幾頁走現場,因此 build 產生了伺服器端的進入點。它描述的是建置模式,和 output 是兩個設定;output: "static"mode: "server" 可以同時成立。只看 server 這個字,很容易延伸出「adapter=server」的誤解。

把同一個 blog 單篇分別寫成名冊版與現場版

同樣是「顯示一篇 blog」,名冊版和現場版的差異直接寫在程式裡。

名冊版,blog/[...slug].astro(build 時列舉全部):

---
import { getCollection, render } from 'astro:content';
export const prerender = true; // 顯式標註走 SSG;static 專案其實不寫也一樣

export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  return posts.map((post) => ({
    params: { slug: post.id }, // v6 用 post.id,不是舊的 post.slug
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post); // v6 是函式 render(entry),不是 entry.render()
---

現場版,demos/post/[id].astro(有人開才即時抓一筆):

---
export const prerender = false; // 就這一行,這頁改走現場
import { getEntry, render } from 'astro:content';

const { id } = Astro.params; // 不列舉了,直接從網址那段拿
if (id === undefined) return Astro.redirect('/404');

const post = await getEntry('blog', id); // getEntry 抓一筆,不是 getCollection 全撈
if (post === undefined || post.data.draft) return Astro.redirect('/404');

const { Content } = await render(post);
---

差別濃縮成三點:

  1. 名單哪來:名冊版要 getStaticPaths 列出全部;現場版不寫 getStaticPaths,官方明說 on-demand 路由不該用它。
  2. 參數哪來:名冊版從 Astro.props 拿 build 時就塞好的資料;現場版從 Astro.params 拿當下網址那段,再自己 getEntry 去查一筆。
  3. 查不到怎麼辦:現場版要處理「這個 id 不存在」的情況,Astro.redirect('/404');名冊版不用,因為列舉的時候就不會有不存在的網址。

兩個檔在同一個專案裡,output 和 adapter 都相同;只有 prerender 不同,建置結果就不同。

build 完,親眼看它們去了哪

光看程式碼還是抽象。跑完 npm run build,去 dist/ 翻,兩頁的落點完全不同:

  • 名冊版 blog/[...slug] → 產出 dist/client/blog/day-01-why-astro/index.html 這種純靜態 HTML。build log 裡那份「prerendering static routes」清單,15 篇文章頁全在上面。
  • 現場版 demos/post/[id] → 沒出現在那份 prerender 清單裡,dist/client/demos/ 底下也沒有 post/。它被編譯進 dist/server/chunks/,是一段有人請求才會跑的伺服器程式。

在同一個 static 專案、同一個 Cloudflare adapter 裡,名冊版進 dist/client/(靜態檔),現場版進 dist/server/(伺服器程式);差別仍由 prerender 決定。

那,到底該選哪個?

分清名冊和現場後,選擇只剩兩個問題:

  1. 這頁的內容,是不是每個訪客看到的都一樣,而且從 build 完到下次發版之間不會變?→ 是,就走名冊(SSG)。內容站的文章、標籤頁、關於頁,幾乎都是這種。
  2. 這頁需不需要當下才知道的東西,像是誰登入了、現在幾點、網址帶了什麼 query、要即時查一次資料庫?→ 需要,才走現場(SSR)。

內容站絕大多數符合第一種條件。這個 capstone 因此預設 static,文章頁全部走名冊:它們從 build 完到下次發文之間都不會變,沒有必要讓伺服器在每次請求時重算。

對照到這個專案,寫法如下:

我要做的頁 檔名 怎麼寫 參數哪來 資料哪來
blog 逐篇頁(內容 build 時就定了) blog/[...slug].astro 不必寫 prerender(吃 static 預設) getStaticPathsparams getCollection 全撈再 .map()
標籤頁 / 分頁列表 tags/[tag].astroblog/[page].astro 不必寫 prerender;分頁用 paginate() getStaticPaths getCollection + 篩選/分頁
要讀當前登入者、即時查詢的頁 user/[id].astro export const prerender = false Astro.params(不寫 getStaticPaths) getEntry / DB / Astro.locals

常見混淆從哪裡來

社群討論和文件裡,這類混淆到處都有,而且方向不只一個:

  • 把 adapter 當成 server。Astro 的 on-demand 說明明確寫道:你的站就算完全靜態,也可以裝 adapter;裝了 adapter 不會改變預設行為,你仍要明確設 output: 'server' 才會全站 SSR。這正是前面那個「裝了 adapter=server」的誤解。
  • 在 server output 中沿用 getStaticPaths()。GitHub 上有使用者回報「設了 output: 'server' 後,getStaticPaths() 被靜默忽略」。同一個「output 和路由行為的關係」,從兩個方向都有人踩。
  • 更常見的是教學內容過期。一堆「WordPress 轉 Astro」的文章確實把 SSR / SSG 拿來當賣點,但其中不少還在教人設 output: 'hybrid',這個選項 Astro 5 就移除了,它原本的行為(靜態站也能有幾頁走現場)現在已經內建成 output: 'static' 的預設。

output: 'hybrid' 的變化,剛好對應這個系列一直在分的兩種知識:

  • 持久的(值得深學,不會過期):「動態路由=一檔多網址」、「build 時列舉 vs 請求時即時」這組概念。名冊和現場的差別,換個框架也還在。
  • 易變的(只學怎麼查,會變)getStaticPaths 的回傳長怎樣、post.id 還是舊的 post.slugoutput: 'hybrid' 還在不在、params 的值一定要是字串,這些每個大版本都可能動。別背,學會去官方的 routing 和 on-demand 文件查,並且判斷手上這篇教學過期了沒。看到還在推 hybrid 的,你就知道它至少停在 v5 以前。

選 SSG 還是 SSR,只問這件事

決定一頁走名冊還是現場的,只有 outputprerender。選的時候只問一句:「這頁需要當下才知道的東西嗎?」需要就走現場,不需要就走名冊。

Day 16:分頁與轉址延續名冊這一側:文章變多後,接著會遇到分頁與轉址。下一篇會看 getStaticPathspaginate() 如何把文章切成多頁,以及 Astro 如何一次處理 redirects。

本日程式碼:step-15|只看這天的改動:step-14...step-15


上一篇
文章一多就翻不完,即時搜尋該交給誰做?
下一篇
文章一多就要分頁與轉址,Astro 怎麼一次處理好?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言