收一張表單,常見做法是:前端用 fetch 送資料,後端開一支 API
route 接資料。前端驗一次,讓使用者當場看到錯誤;後端再驗一次,防止有人繞過前端。這樣能運作,但每多一張表單,fetch、欄位驗證和錯誤處理都要再寫一遍。
Astro 的另一個選項是 Action。前端可以像呼叫一般函式一樣await actions.feedback(...),把資料交給後端處理。底層仍是一個送到伺服器的請求,邏輯也在伺服器執行;Astro 會產生那段fetch,回傳值自動帶型別,並先用 Zod 在伺服器驗證輸入。這篇用一張 feedback 表單,說明 Action 如何簡化「呼叫後端」,以及它和 API
route 的分工。
自己的網站前端呼叫自己的後端,例如送出這張 feedback 表單,優先用 Action;需要提供一個網址給站外使用者或其他程式呼叫,就自己寫 API
route。
判斷時看的是呼叫者。自己的網站前端呼叫自己的後端,可以用 Action,不限於表單;按讚、收藏文章、送出其他動作都算。需要讓站外使用者、其他程式或 webhook 呼叫一個明確的 HTTP 端點,則自己寫 API
route。表單只是這篇的範例,Action 處理的是整個「站內前端呼叫自己的後端」情境。
實作時,兩者差在框架代辦多少工作。Action 已經處理後端呼叫:不用手寫fetch,回傳值自動帶型別,輸入可先在伺服器用 Zod 驗證,錯誤格式也一致;純 HTML 表單在沒有 JavaScript 時仍能送出。自己寫 API
route 則直接處理「請求進、回應出」,HTTP 方法、標頭和狀態碼都由你控制,也都要自己實作。
一個 Action 分兩邊:伺服器上宣告它做什麼,前端呼叫它。
伺服器這邊,所有 action 放在一個固定位置 src/actions/index.ts:
import { defineAction } from "astro:actions";
import { z } from "astro/zod";
export const server = {
feedback: defineAction({
accept: "form", // 收 HTML 表單資料(不寫則預設收 JSON)
input: z.object({
rating: z.coerce.number().int().min(1, "請選 1–5 分").max(5, "請選 1–5 分"),
message: z.string({ error: "請至少寫 5 個字" }).min(5, "請至少寫 5 個字"),
}),
handler: async ({ rating, message }) => {
// 這篇只驗證 + 回傳,先不存。把它存進資料庫是 Day 22 的事。
return { ok: true, rating, preview: message.slice(0, 40) };
},
}),
};
四個項目的作用如下:
defineAction:宣告一個 action 的函式,告訴 Astro「這個動作收什麼、做什麼」。accept: 'form':說明這個 action 收的是 HTML 表單送來的資料(FormData)。不寫的話它預設收 JSON。input:用 Zod 描述送進來的資料格式。z 從 astro/zod 匯入,由 Astro 提供,不用另外安裝。z.coerce.number()z.string().min(5) 會確認輸入是文字,而且至少有 5 個字。Dayhandler:驗證通過後才會跑的函式,拿到的 rating、message 都已經是檢查過、型別正確的值。前端這邊,呼叫它不必手寫 fetch:
import { actions, isInputError } from "astro:actions";
const { data, error } = await actions.feedback(formData);
if (isInputError(error)) {
// 伺服器那道 Zod 沒過:error.fields.rating 是 ['請選 1–5 分']、error.fields.message 是 ['請至少寫 5 個字']
// 就是你在上面 schema 裡寫的那幾句,每個欄位一個訊息陣列,拿來顯示就好
}
actions.feedback(formData) 會把表單送到伺服器;輸入通過驗證後,才執行 handler,並把 { data, error }
傳回前端。輸入若沒通過伺服器的 Zod 驗證,例如有人繞過前端,或之後加入「這個 email 是否註冊過」這類只有伺服器能判斷的規則,isInputError(error)
會是 true。此時 error.fields 會列出錯誤欄位與對應訊息,內容就是 schema 裡設定的文字。
實際執行時(demo 在實作專案的/demos/feedback-action),表單保留瀏覽器原生的必填與長度檢查(required、minlength)。欄位沒填好,瀏覽器會直接阻止送出,這一層負責即時回饋;填好評分 5 和一段內容後,資料才送到伺服器,執行handler,再回傳「收到了,謝謝!評分 5、內容『…』」。伺服器仍會用 Zod 驗證一次。即使用 curl
直接送出請求來繞過瀏覽器的前端檢查,無效資料也會在伺服器被擋下;關掉 JavaScript 時,伺服器驗證同樣不受影響。

畫面上會即時顯示結果、不換頁的是 Vue island 版表單(島嶼、client:* 是 Day
8 的主題)。同一個 Action 也能接純 HTML 表單,不必依賴 island。
把同一個 action 接到一張純 HTML 表單上,連前端 JavaScript 都不用:
---
import { actions } from 'astro:actions';
export const prerender = false; // 這頁要在收到請求時才產生(見下方說明) const result =
Astro.getActionResult(actions.feedback);
---
<form method="POST" action="{actions.feedback}">
<!-- 評分、留言欄位(一樣可以帶 required、minlength) -->
<button type="submit">送出</button>
</form>
{result?.error &&
<p>送出失敗,請檢查欄位。</p>
}
把 action 直接指到 actions.feedback,表單就會用最原始的瀏覽器行為 POST 出去,伺服器跑完 action、頁面重新產生,結果用Astro.getActionResult 取回來顯示。
這裡的「沒有 JavaScript」是指瀏覽器端沒有 JavaScript:例如使用者關掉 JavaScript,或 island 還沒 hydrate。---
之間的程式雖然也是 JavaScript,但只在伺服器執行,用來產生 HTML,不會送到瀏覽器。
即使 island 尚未載入、使用者關掉 JavaScript,或瀏覽器擋下腳本,這張表單仍能送到伺服器並完成驗證。required、minlength
也是瀏覽器內建的原生檢查,不依賴 JavaScript。完全依賴手寫 fetch 的表單則不同:沒有 JavaScript,那段 fetch
不會執行,按鈕按下去也不會送出請求。「沒有 JavaScript 也能用,有 JavaScript 時再提供更順的互動」就是漸進增強;這是選 Action 的實際理由,不只少寫幾行程式碼。
本系列專案採靜態輸出,頁面預設在 build 時產生(Day
1 介紹過這個預設)。這張純 HTML 表單 POST 到 Action 後,頁面要在收到請求時重新產生,因此必須加export const prerender = false。需要在請求當下處理資料的 API route 也一樣。
需要自行定義 HTTP 介面時,就自己寫 API route。這類會回傳 Response 的路由,Astro 官方文件稱為 Endpoint:
| 情境 | 用 Action | 自己寫 API route |
|---|---|---|
| 自己網站的表單、按鈕送資料 | ✅ | |
想要型別安全、少寫 fetch 與樣板 |
✅ | |
| 沒有 JavaScript 也要能送(漸進增強) | ✅ | |
| 要提供給外部、其他程式或 webhook 呼叫 | ✅ | |
| 要回 RSS、JSON feed、檔案,或自訂標頭/狀態碼 | ✅ | |
| 需要一個 GET 端點 | ✅ |
自己寫 API route 時,會在 src/pages 底下放一支回傳 Response 的程式。以同一個 feedback 功能為例:
import type { APIRoute } from "astro";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const data = await request.json().catch(() => null);
const rating = Number(data?.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return new Response(JSON.stringify({ ok: false, error: "rating 必須是 1–5" }), { status: 400 });
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
API route 需要自行用 if 驗證、組合錯誤回應,並設定狀態碼。 Action 把這些工作交給 Zod 和 Astro;API
route 則讓你直接控制 HTTP 回應。需求若是一個可用 curl 直接呼叫的網址,例如提供給手機 App、其他服務或資料 feed,API
route 會比較合適。Day 20 已示範如何用 API route 回傳 JSON 和 RSS,這裡只比較兩者的分工。
required、minlength,或在離開欄位時用 JavaScript 檢查,讓使用者立即看到錯誤。伺服器驗證則防止有人繞過前端,兩者要並存。若想讓規則只定義一次,可以抽出同一個 Zodz 來自 astro/zod,不用另外執行 npm install zod。這和 Day 10 從 astro:content 取得的是同一套 Zod。export const prerender = false;否則頁面或路由會在 build 時產生固定結果,無法處理使用者送來的請求。null,不是空字串。 上面的 message 因此用 z.string({ error: '…' })Invalid input: expected string, received null。同一個 feedback action 可以同時服務 Vue island 表單和純 HTML 表單。它省去手寫 fetch 和另開 API
route,回傳值自動帶型別,錯誤格式也一致;沒有 JavaScript 時,純 HTML 表單仍能使用。只要 Action 定義了input,Zod 就會在伺服器驗證輸入;前端的即時驗證仍要保留,兩者各自處理不同問題。
對內容站來說,這種分工能保留 Astro 預設 0 JavaScript 的做法(Day
1 已做過量測):靜態內容維持靜態,需要和後端互動的地方再加入 Action。互動只加在需要的位置,不必為了表單改寫整個網站。
至於這張表單,現在收得到、驗得過,但送出後就沒了:handler
只驗證、只回傳,沒把資料留下來。下一步把它接上資料層,用 Drizzle ORM +
Turso/libSQL 把每一筆 feedback 真的存進去,之後才查得到、算得出來。那是 Day 22 要做的事。
本日程式碼:step-21|只看這天的改動:step-20...step-21