先看內容正本與編輯流程,再選 CMS。Google
Sheets 適合小型、欄位固定的表格資料;Keystatic 適合想保留 Markdown 與 Git,又需要編輯介面的團隊;Strapi 則把內容模型、權限與發布流程做得更完整,代價是多維護一套 server 和資料庫。
三種方案都能把內容交給 Astro。選型要比較誰負責資料、編輯者怎麼工作,以及內容何時算發布;fetch()
的寫法不是主要差異。技術基準是 Astro 7.1.1,官方文件查證與實測日期為 2026-07-24。
不論內容放在哪,build-time Content Layer 的流程都是:
編輯者
→ 內容正本(Sheet/Git repo/Strapi DB)
→ Astro loader
→ schema 驗證與 Content Layer store
→ build
→ 靜態頁面
loader 解決「資料怎麼進 Astro」,不會接管原始資料的 ownership。Sheet 仍由 Google
Drive 管,Keystatic 的檔案仍在 repo,Strapi 的內容仍在自己的 DB。Astro store 是供查詢與建置使用的衍生資料。
來源改了,線上靜態頁不會跟著更新。build-time loader 只在 content
sync/build 時讀來源;要發布變更,仍需由 webhook 或 CI 再跑一次 build。這跟
Day 15:SSG 與 SSR的頁面產生時機是同一個取捨。
Astro 7 也有 request-time 的 live loader,但它會把遠端 API 的延遲、quota 與可用性放進每次請求,並失去 runtime MDX
render 和 Astro 圖片最佳化。這個內容站的文章、RSS、JSON 都已採 build-time collection,因此繼續沿用同一個執行模型。
| 方案 | 工作流與代價 |
|---|---|
| Google Sheets | 正本在 Google Drive,試算表使用者可以直接編輯;缺少內容模型與 Draft & Publish,更新後要重新 build。 |
| Keystatic | 正本是 Git repo 裡的內容檔,schema 與 branch 流程留在 codebase;線上 Admin 會多出認證與 server runtime。 |
| Strapi 5 | 正本在 Strapi DB,後台提供 relations、media 與 Draft & Publish;團隊要維護 CMS server、DB、備份與升級。 |
這張表裡沒有「全面最好」的方案。內容正本想留在 Git 時,Strapi 就算功能最多,也不構成搬進 DB 的理由;團隊若需要正式 draft、relations 與角色分工,再用 Sheet 補欄位慣例也不合適。
許多專案一開始不用 CMS,內容正本直接放在程式碼裡。
一個上線中的多語系品牌官網走的是這條路。它的文案分成兩處:四份語系 JSON 放 UI 字串與段落文案,其餘標題與圖片直接硬編碼在元件的 template 裡。編輯流程等於改 code、送 PR、重新部署。
專案早期這樣做有其道理。文案量少、只有工程師在改,也還沒有編輯者要進來,多一套 CMS 就會多一套維護工作。等編輯者變成非工程師,或語系增加,這套做法的代價就會出現。這個專案兩件事都發生了:共有四個語系,而且 JSON 的 key 數量已經不一致,英文 89 個、其他三個語系各 83 個。少掉的那六個字串沒有任何機制會提醒;缺 key 時,取值函式會回傳字串NONE,直接渲染到正式頁面上。
內容正本與編輯流程決定了要補哪個缺口。上表三個方案各自對應不同需求:Sheets 是「讓非工程師能編輯」,Keystatic 是「保留 Git 正本但給編輯介面」,Strapi 是「內容模型、權限與發布狀態」。無論選哪一個,schema 驗證那一關都不能省(Day 12 那條線):正本搬到哪裡,缺欄位都應該讓 build 失敗,不能讓讀者看到NONE。
該不該把內容從 code 裡搬出來,可以先問「誰要改它」和「改完誰負責確認沒缺」,不必先數文案有幾行。只要其中一題的答案不是工程師,就值得比較上表的方案。
Google
Sheets 省掉一個學習門檻:編輯者已經會用。共享、留言、版本紀錄與權限都在現成介面裡,欄位固定的名單、推薦語、活動時程或價格表,不必先教一套 CMS。
Astro 沒有官方 Google Sheets loader。正式接法通常是 custom loader 呼叫spreadsheets.values.get,把第一列當欄位、後續每列轉成 entry,再交給 Zod 驗證。私有 Sheet 可用 OAuth 或 service
account;只讀情境應採最窄的 read-only scope。
代價是內容規則幾乎都要自己補:
所以 Sheets 適合「幾列結構化內容需要低門檻協作」,不適合作為完整文章 CMS。
Keystatic 的方向不同。它用 schema 產生 Admin UI,內容仍可存成 Markdown、Markdoc、MDX、YAML 或 JSON。local
mode 寫本機檔案,GitHub mode 直接讀寫 repo,Keystatic Cloud 也仍連到指定的 GitHub repo。
這種模式適合已採用 Markdown 與 Git review,又希望寫作者不必每次進 IDE 改 frontmatter 的團隊;Markdown 的可攜性可參考
Day 9:Markdown 的可攜性。內容 diff、branch 與 rollback 仍沿用既有工具。
不過,2026-07-24 的官方 README 仍把 Keystatic 標為experimental。專案沒有停更,也已加入 Astro
7 支援,但「活躍維護」不等於「穩定承諾已完成」。
導入 Keystatic 前,還要確認部署條件。官方 Astro 安裝會加入 React、Markdoc、@keystatic/core、@keystatic/astro,production
Admin 需要 server-side code 與 Node.js APIs。這個 capstone 的優先部署環境是 Cloudflare
Workers,而 Keystatic 官方沒有對應的 Workers production 指引。若只在本機 local
mode 編輯,可以在 production 關掉 Admin;若要提供線上編輯,還得確認認證與 Node runtime 的部署位置。
這個實作沒有安裝 Keystatic。只有一個遠端結構化來源,還不足以換整套編輯 runtime。
Strapi 5 會替 content type 產生 REST endpoints,支援 filter、sort、pagination、fields、relations、locale 與status=draft|published。Content Manager 能管理 components、dynamic zones、media;Draft & Publish 也能依 content
type 開啟。
這些能力適合多人內容團隊。編輯者不需要理解 repo,內容模型與發布狀態也不靠試算表欄位暗號維持。對 Astro 而言,接法仍可是一個 custom
loader:build 時呼叫 Strapi REST API、驗證 response,再寫入 Content Layer。
Strapi 要另外啟動一個 Node server,加上一個 SQL DB;自架時要處理備份、升級、監控與 API token。Content
types 預設 private,必須設定 public permission 或提供正確權限的 token。靜態 Astro 頁要在 publish 後更新,還要用
Strapi webhook觸發 CI 或部署。
如果需求已經包含多角色、relations、media 與正式 preview,維護 Strapi 的成本才合理。若內容只有六張資料卡,Strapi 帶來的維運工作反而多過內容管理需求。
| 問題 | 答案偏向 |
|---|---|
| 編輯者只需要改固定欄位,而且已熟悉試算表? | Google Sheets |
| 內容要留在 repo,Git diff/branch 是既有發布流程? | Keystatic |
| 需要 relations、media、角色權限與 Draft & Publish? | Strapi |
| 團隊能不能維護 production Admin runtime 或 CMS server/DB? | 不能時,先選更小的方案 |
選型時先回答這四個 workflow 問題。答案清楚後,再比較套件版本、費用與 UI,這個順序能避免許多「裝完才發現正本放錯地方」的重工。
最小實作以 Google Sheets 驗證遠端表格內容,30 篇文章仍留在 Git。資料源是 Google 官方 quickstart 的 sample
spreadsheet,公開 CSV 共有 30 筆人物資料。
資料進獨立的 sheetProfiles collection:
import { defineCollection } from "astro:content";
import { googleSheetProfileLoader } from "./loaders/google-sheet-profile-loader";
const sheetProfiles = defineCollection({
loader: googleSheetProfileLoader({
url: "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/gviz/tq?tqx=out:csv&sheet=Class%20Data",
}),
});
export const collections = {
sheetProfiles,
};
loader 先檢查 HTTP,再驗證前六個表頭。每列要經兩層驗證:來源 tuple 確認 cell shape,parseData() 再套用 collection
schema。最後才寫進 store:
let rows = parseCsv(await response.text());
let headers = rows.shift();
if (!headers) {
throw new Error("Google Sheet CSV did not contain a header row.");
}
assertExpectedHeaders(headers);
for (let [index, sourceRow] of rows.entries()) {
let parsedRow = googleSheetSourceRowSchema.parse(sourceRow);
let [studentName, gender, classLevel, homeState, major, activity] = parsedRow;
let id = createProfileId(studentName);
let data = await parseData({
id,
data: {
studentName,
gender,
classLevel,
homeState,
major,
activity,
rowNumber: index + 2,
sourceUrl: sourceUrl.href,
},
});
store.set({ id, data, digest: generateDigest(data) });
}
這個 sample 的姓名不重複,範例才用姓名產 ID;loader 遇到重複 ID 會直接讓 build 失敗。正式內容應在 Sheet 加一欄不隨標題變動的id。否則編輯者改名就會讓 entry identity 一起改,內鏈或引用也跟著失效。
CSV parser 只負責格式。實作用一個小型 state machine 處理引號、逗號、CRLF 與 quoted
newline;資料規模更大或格式更複雜時,換成熟 CSV library 即可,collection consumer 不用變。
blog目前 blog
collection 會同時餵首頁、搜尋、分頁、文章 route、Day 20 的 RSS 與 JSON。它的 schema 還要求title、description、day、pubDate 與可渲染正文。
sample spreadsheet 沒有這些欄位。若仍轉成 blog entry,30 筆人物資料會進入文章列表、RSS 與搜尋,直接污染既有 consumer。
既有 blog schema 維持不變,只新增一個 data collection 與 /demos/cms-content-source。這個分層沿用
Day 10:Content Collections、Day 11:custom loader和
Day 12:schema 驗證的做法:
---
import { getCollection } from 'astro:content';
const profiles = (await getCollection('sheetProfiles')).toSorted( (a, b) => a.data.rowNumber - b.data.rowNumber, );
---
<ol>
{ profiles.slice(0, 6).map((profile) => (
<li>{profile.data.studentName} · {profile.data.major}</li>
)) }
</ol>
來源換成 Sheet 後,頁面仍是普通 Astro template,也不需要 Vue island。

實測沿「來源 → store → output → browser」逐層核對:
| 驗收層 | 結果 |
|---|---|
| Google Sheet CSV | 30 筆資料列,前六欄表頭符合預期 |
| Content Layer | sheetProfiles 30 entries |
| 靜態頁 | 顯示前 6 張卡,順序對應 Sheet row 2–7 |
| Client island | <astro-island> 0 個 |
| 1440px 桌面 | innerWidth = scrollWidth = 1440 |
| 390px 手機 | innerWidth = scrollWidth = 390,卡片為單欄 |
| Accessibility | axe WCAG A/AA:0 violations、0 incomplete |
頁面仍有全站共用的 <ClientRouter /> scripts;本次只能確認「這個 CMS demo 沒有 client island
hydration」,不能說整頁零 JavaScript。
最終 build 也產出 29 篇文章、5 個分頁、RSS、JSON 與 Day 11 loader demo。loader
demo 是 29/3/1:相較 baseline 的 28/3/1,只多了本篇 blog entry,30 筆 sheetProfiles 沒有混進三張既有 loader 卡。
第一,遠端 CSV 連不到、HTTP 非 2xx 或表頭被改掉,content
sync 會失敗,整次靜態 build 也會停。這是刻意的 fail-fast:比部署一份欄位錯位的頁面安全。
第二,Sheet 更新後仍要 rebuild。若要自動化,可把編輯流程接到 CI;Google Sheets 沒有像 Strapi publish
event 那樣直接的 CMS webhook,通常要用 Apps Script 或排程補觸發機制。
第三,公開資料與正式私有內容要分開。這個 demo 使用 Google 官方 sample,不含專案私人資料;內部 Sheet 應改走 Sheets
API、service account、read-only scope,secret 只放 build environment。處理 secret 的方式可沿用
Day 22:server env 邊界,不能把憑證寫進 loader URL 或 client bundle。
Google Sheets 的編輯門檻最低,Keystatic 為 Git-backed
content 加上編輯 UI。Strapi 提供完整內容平台,但 server 與 DB 也會進入維運範圍。
小型結構化內容先用 Sheet,Git 是內容正本時看 Keystatic,正式內容團隊再評估 Strapi。無論選哪一個,都要先確認三件事:內容由誰擁有、如何驗證、哪個事件才算發布。
這次用 Google Sheets 完成「遠端來源 → Content Layer → 靜態頁面」,既有 blog consumer 也維持不變。下一篇 Day
30 會回到整個 capstone:什麼專案適合 Astro,什麼情況別硬上。
今日驗收:Google 官方 sample spreadsheet 以 custom loader 載入 sheetProfiles 30
entries;/demos/cms-content-source 顯示前 6 筆、client island hydration 為 0,1440px/390px 無水平溢位,axe WCAG
A/AA 為 0 violations,Astro production build 通過。