iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Modern Web

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

Astro 收表單,什麼時候用 Action、什麼時候自己寫 API?

  • 分享至 

  • xImage
  •  

收一張表單,常見做法是:前端用 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 分兩邊:伺服器上宣告它做什麼,前端呼叫它。

伺服器這邊,所有 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 描述送進來的資料格式。zastro/zod 匯入,由 Astro 提供,不用另外安裝。z.coerce.number()
    會把表單送來的字串轉成數字再檢查;z.string().min(5) 會確認輸入是文字,而且至少有 5 個字。Day
    10 用同一套工具驗證 frontmatter,這裡改為驗證使用者送來的資料。
  • handler:驗證通過後才會跑的函式,拿到的 ratingmessage 都已經是檢查過、型別正確的值。

前端這邊,呼叫它不必手寫 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),表單保留瀏覽器原生的必填與長度檢查(requiredminlength)。欄位沒填好,瀏覽器會直接阻止送出,這一層負責即時回饋;填好評分 5 和一段內容後,資料才送到伺服器,執行
handler,再回傳「收到了,謝謝!評分 5、內容『…』」。伺服器仍會用 Zod 驗證一次。即使用 curl
直接送出請求來繞過瀏覽器的前端檢查,無效資料也會在伺服器被擋下;關掉 JavaScript 時,伺服器驗證同樣不受影響。

實作專案的 Astro Actions 表單 demo 頁,上半是 Vue island 版表單、下半是純 HTML 漸進增強版表單,都接同一個 feedback action

畫面上會即時顯示結果、不換頁的是 Vue island 版表單(島嶼、client:* 是 Day
8 的主題)。同一個 Action 也能接純 HTML 表單,不必依賴 island。

沒有 JavaScript,這張表單照樣能送

把同一個 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,或瀏覽器擋下腳本,這張表單仍能送到伺服器並完成驗證。requiredminlength
也是瀏覽器內建的原生檢查,不依賴 JavaScript。完全依賴手寫 fetch 的表單則不同:沒有 JavaScript,那段 fetch
不會執行,按鈕按下去也不會送出請求。「沒有 JavaScript 也能用,有 JavaScript 時再提供更順的互動」就是漸進增強;這是選 Action 的實際理由,不只少寫幾行程式碼。

本系列專案採靜態輸出,頁面預設在 build 時產生(Day
1 介紹過這個預設)。這張純 HTML 表單 POST 到 Action 後,頁面要在收到請求時重新產生,因此必須加
export const prerender = false。需要在請求當下處理資料的 API route 也一樣。

那什麼時候該自己寫 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,這裡只比較兩者的分工。

幾個容易誤會的地方

  • Action 的 Zod 在伺服器驗證輸入,不能取代前端驗證。 前端仍要提供即時驗證,例如
    requiredminlength,或在離開欄位時用 JavaScript 檢查,讓使用者立即看到錯誤。伺服器驗證則防止有人繞過前端,兩者要並存。若想讓規則只定義一次,可以抽出同一個 Zod
    schema 供前後端共用。
  • Action 不會取代 API route。 它適合簡化站內前端呼叫自己的後端;需要明確的對外 HTTP 合約時,API route 更合適。
  • Action 偏向 POST。 它是為「送資料、觸發動作」設計的。要用 GET 把資料拿出來、做 feed,就自己寫一支 API route。
  • z 來自 astro/zod,不用另外執行 npm install zod。這和 Day 10 從 astro:content 取得的是同一套 Zod。
  • 純 HTML 表單 POST 到 Action 的頁面,以及需要在請求當下執行的 API route,都要由伺服器即時處理。
    本系列專案採靜態輸出,因此要加
    export const prerender = false;否則頁面或路由會在 build 時產生固定結果,無法處理使用者送來的請求。
  • 空白的表單欄位會變成 null,不是空字串。 上面的 message 因此用 z.string({ error: '…' })
    提供中文型別錯誤;否則空欄位若繞過前端送到伺服器,會看到 Zod 預設的英文
    Invalid input: expected string, received null

Action 簡化的是站內前後端呼叫

同一個 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


上一篇
為什麼說 Astro 不只是靜態網站?用 endpoints 建立 RSS 與 JSON
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言