Web Day 26 用 Next.js 串接 FastAPI(二)寫入與表單
執行需求:CPU 可跑。昨天把「Next.js 15 讀取 FastAPI JSON」走完,今天進入「寫入」:在 Server Component 裡呼叫 Server Action、新增預約、刪除預約、處理表單驗證與錯誤狀態。我們延用 Day 25 的後端 JSON API,只在前端加上一個完整的寫入流程;後端在貫穿專案「預約管理系統」會用同一套端點。
引言
昨天我們建立了一個只會讀的 Next.js 介面:頁面渲染時從 FastAPI 拿資料,顯示清單與細節。但「使用者新增預約」「刪除預約」「取消預約」這類寫入操作,需要的是「使用者填表單、按送出、伺服器接收、處理、回到原本頁面或顯示錯誤」這個流程。React 19 提供一個全新的機制叫 Server Actions:你可以把一個 async function 直接掛在 <form action={...}> 上,Next.js 會自動把表單序列化、送到伺服器、執行函式、再把回傳的內容重新渲染頁面。整個過程不寫 API 端點、不寫 fetch、不寫 JSON 序列化——這對「表單→寫入→回到頁面」這個最常見的流程是巨大的簡化。
這篇文章要做四件事:第一,在 FastAPI 端加上 POST 與 DELETE 的 JSON 端點(含 Pydantic 驗證);第二,用 zod 在前端做表單驗證;第三,用 React 19 的 useActionState 與 useFormStatus 處理「送出中」「錯誤訊息」「欄位錯誤」;第四,用 pytest 為寫入端點寫測試。我們刻意不在前端做樂觀更新(optimistic update)或用 React Query,把範圍控制在「表單能送出、錯誤能顯示」。
後端:POST 與 DELETE 的 JSON 端點
延用 Day 25 的 main_api.py,加上寫入端點。FastAPI 用 Pydantic BaseModel 自動驗證請求主體,這對前端是極大的保護:使用者送來的 JSON 結構不對、後端會回 422 與清楚的錯誤訊息。
# 追加到 main_api.py(Day 25 已有 BookingOut,這裡加 BookingIn)
from typing import Annotated
import uuid
from datetime import datetime, timezone
from fastapi import Body, FastAPI, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
class BookingIn(BaseModel):
customer: str = Field(min_length=1, max_length=64)
slot: str = Field(min_length=10, max_length=32)
service: str = Field(default="教練課", min_length=1, max_length=32)
@field_validator("slot")
@classmethod
def slot_must_be_iso8601(cls, v: str) -> str:
# 簡單驗證:能不能用 datetime.fromisoformat 解析
try:
datetime.fromisoformat(v)
except ValueError as e:
raise ValueError("slot 必須是 ISO 8601 字串") from e
return v
@app.post(
"/api/bookings",
response_model=BookingOut,
status_code=status.HTTP_201_CREATED,
)
def create_booking(payload: BookingIn):
booking = {
"id": str(uuid.uuid4()),
"customer": payload.customer,
"slot": payload.slot,
"service": payload.service,
"status": "confirmed",
"created_at": datetime.now(timezone.utc).timestamp(),
}
BOOKINGS.append(booking)
return booking
@app.delete("/api/bookings/{booking_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_booking_api(booking_id: str):
before = len(BOOKINGS)
BOOKINGS[:] = [b for b in BOOKINGS if b["id"] != booking_id]
if len(BOOKINGS) == before:
raise HTTPException(status_code=404, detail="找不到此預約")
return None
@app.patch("/api/bookings/{booking_id}/cancel", response_model=BookingOut)
def cancel_booking_api(booking_id: str):
for b in BOOKINGS:
if b["id"] == booking_id:
b["status"] = "cancelled"
return b
raise HTTPException(status_code=404, detail="找不到此預約")
這段展示了 Pydantic v2 的幾個重點。Field(min_length=, max_length=) 限制字串長度;@field_validator("slot") 自訂驗證(這裡要求 ISO 8601 格式)。FastAPI 會把這些驗證規則自動轉成 OpenAPI schema 與 422 錯誤訊息。status_code=status.HTTP_201_CREATED 明確告訴 API 消費者「新增成功」的標準 status code。204 No Content 是 DELETE 慣例:成功不回主體、避免 client 端以為有資料要解析。
三個端點:POST 新增、DELETE 刪除、PATCH 取消(只更新狀態,不刪除記錄)。實務上取消比刪除友善,使用者取消後資料還在,方便做審計、報表、回復。這跟預約管理系統的需求對應:客戶可能「取消」預約但不該徹底消失。
前端:Server Action 與表單
先建立 zod schema 在前端做表單驗證。zod 是 TypeScript 生態最常用的 schema 驗證函式庫,跟 Pydantic 風格相似:
// src/lib/booking-schema.ts
// 用 zod 定義前端表單驗證規則
import { z } from "zod";
export const bookingSchema = z.object({
customer: z
.string()
.min(1, "請輸入客戶姓名")
.max(64, "姓名最多 64 字"),
slot: z
.string()
.min(10, "請選擇預約時段")
.refine((v) => !Number.isNaN(Date.parse(v)), {
message: "時段格式不正確",
}),
service: z.string().min(1, "請選擇服務").max(32),
});
export type BookingFormValues = z.infer<typeof bookingSchema>;
zod schema 的 min/max 對應到 Pydantic 的 min_length/max_length,refine 提供自訂驗證(這裡用 Date.parse() 驗證時段是否為合法日期字串)。z.infer<typeof bookingSchema> 自動推導 TypeScript 型別,讓前端程式碼享有完整的型別檢查。今天我們用手動處理錯誤訊息,明天(Web Day 27)會把認證整合進來。
接下來是 Server Action。它在伺服器端執行,但可以被 client component 透過 <form action={...}> 觸發:
// src/app/bookings/actions.ts
// Server Actions:直接執行在伺服器、不用寫 fetch
"use server";
import { revalidatePath } from "next/cache";
import { listBookings, getBooking } from "@/lib/api";
import { bookingSchema } from "@/lib/booking-schema";
const API_BASE = process.env.API_BASE ?? "http://127.0.0.1:8000";
export type ActionState = {
ok: boolean;
message?: string;
fieldErrors?: Partial<Record<keyof BookingFormValues, string>>;
};
export async function createBookingAction(
_prev: ActionState,
formData: FormData,
): Promise<ActionState> {
// 解析 FormData 為物件
const raw = {
customer: String(formData.get("customer") ?? ""),
slot: String(formData.get("slot") ?? ""),
service: String(formData.get("service") ?? "教練課"),
};
// 前端驗證
const parsed = bookingSchema.safeParse(raw);
if (!parsed.success) {
const fieldErrors: ActionState["fieldErrors"] = {};
for (const issue of parsed.error.issues) {
fieldErrors[issue.path[0] as keyof BookingFormValues] = issue.message;
}
return { ok: false, fieldErrors };
}
// 打 FastAPI(注意:API_BASE 沒 NEXT_PUBLIC_ 前綴,只在伺服器端可見)
const res = await fetch(`${API_BASE}/api/bookings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed.data),
});
if (!res.ok) {
return { ok: false, message: `伺服器錯誤:${res.status}` };
}
// 重新整理 /bookings 頁面
revalidatePath("/bookings");
return { ok: true };
}
export async function deleteBookingAction(bookingId: string) {
const res = await fetch(`${API_BASE}/api/bookings/${bookingId}`, {
method: "DELETE",
});
if (!res.ok && res.status !== 404) {
throw new Error(`刪除失敗:${res.status}`);
}
revalidatePath("/bookings");
}
這個檔案做了幾件關鍵事。第一,"use server" 標記讓整支檔案都是 Server Action,async function 可以被前端 <form> 直接呼叫。第二,revalidatePath("/bookings") 告訴 Next.js「/bookings 頁面的快取失效,下次請求重新抓資料」——這是 Server Component 與 Server Action 整合的關鍵設計:寫入後不需要手動 refetch,Next.js 自動處理。第三,API_BASE 沒有 NEXT_PUBLIC_ 前綴,只在伺服器端可見,這避免把內部 URL 暴露到瀏覽器。
表單元件是用 useActionState 與 useFormStatus 的 client component:
// src/app/bookings/new-form.tsx
// 新增預約表單(client component,含送出狀態與錯誤訊息)
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import {
createBookingAction,
type ActionState,
} from "./actions";
const initial: ActionState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "送出中..." : "新增預約"}
</button>
);
}
export function NewBookingForm() {
const [state, formAction] = useActionState(createBookingAction, initial);
return (
<form action={formAction} style={{ display: "grid", gap: 8 }}>
<label>
客戶姓名
<input name="customer" required maxLength={64} />
{state.fieldErrors?.customer && (
<span style={{ color: "red" }}>{state.fieldErrors.customer}</span>
)}
</label>
<label>
預約時段
<input name="slot" type="datetime-local" required />
{state.fieldErrors?.slot && (
<span style={{ color: "red" }}>{state.fieldErrors.slot}</span>
)}
</label>
<label>
服務
<input name="service" defaultValue="教練課" required maxLength={32} />
{state.fieldErrors?.service && (
<span style={{ color: "red" }}>{state.fieldErrors.service}</span>
)}
</label>
{state.message && !state.ok && (
<p style={{ color: "red" }}>{state.message}</p>
)}
{state.ok && state.message && (
<p style={{ color: "green" }}>{state.message}</p>
)}
<SubmitButton />
</form>
);
}
useActionState(action, initial) 回傳 [state, formAction]:state 是上次 action 回傳的值,formAction 是包裝過的函式,掛在 <form action> 上即可。useFormStatus() 給 submit 按鈕用,回傳 pending: boolean,讓我們在送出時禁用按鈕並顯示「送出中」。
注意 fieldErrors 的顯示方式:每個欄位下方單獨顯示錯誤,全域錯誤(如伺服器 500)用 state.message 在表單底部顯示。這樣區分「這個欄位填錯了」與「整個操作失敗」,使用者能立刻知道怎麼修正。
最後在清單頁嵌入這個表單:
// src/app/bookings/page.tsx(部分)
import { NewBookingForm } from "./new-form";
export default async function BookingsPage() {
const data = await listBookings();
return (
<main style={{ padding: 24 }}>
<h1>預約清單</h1>
<NewBookingForm />
<p>共 {data.total} 筆</p>
{/* ... list ... */}
</main>
);
}
Server Component 頁面嵌入 client component 表單,是 Next.js 15 最自然的混搭方式:頁面本身是 server(fetch 資料、組 HTML),表單是 client(處理使用者互動)。bundle 維持小體積,SEO 友善,互動流暢。
用 pytest 驗證寫入端點
寫入端點的測試比讀取重要:寫入錯誤可能造成資料污染或遺失。我們用「測試建立→測試驗證失敗→測試刪除→測試刪除不存在的東西」的順序寫:
# test_writes.py
# FastAPI 寫入端點測試(沿用 Day 25 的 conftest)
import pytest
from fastapi.testclient import TestClient
from main_api import app, BOOKINGS
client = TestClient(app)
@pytest.fixture(autouse=True)
def reset():
BOOKINGS.clear()
yield
BOOKINGS.clear()
def test_create_booking_success():
r = client.post(
"/api/bookings",
json={"customer": "王小明", "slot": "2025-08-10T10:00", "service": "重訓"},
)
assert r.status_code == 201
body = r.json()
assert body["customer"] == "王小明"
assert body["status"] == "confirmed"
assert "id" in body
assert len(BOOKINGS) == 1
def test_create_booking_validation_failure():
# 缺 customer → 422
r = client.post("/api/bookings", json={"slot": "2025-08-10T10:00"})
assert r.status_code == 422
assert "detail" in r.json()
# slot 不是 ISO 8601 → 422
r = client.post(
"/api/bookings",
json={"customer": "test", "slot": "明天"},
)
assert r.status_code == 422
def test_create_booking_too_long_customer():
r = client.post(
"/api/bookings",
json={"customer": "x" * 100, "slot": "2025-08-10T10:00"},
)
assert r.status_code == 422
def test_delete_booking_success():
# 先建一筆
r = client.post(
"/api/bookings",
json={"customer": "test", "slot": "2025-08-10T10:00"},
)
booking_id = r.json()["id"]
r = client.delete(f"/api/bookings/{booking_id}")
assert r.status_code == 204
assert r.text == "" # 204 沒有主體
assert len(BOOKINGS) == 0
def test_delete_missing_booking():
r = client.delete("/api/bookings/does-not-exist")
assert r.status_code == 404
def test_cancel_booking():
r = client.post(
"/api/bookings",
json={"customer": "test", "slot": "2025-08-10T10:00"},
)
booking_id = r.json()["id"]
r = client.patch(f"/api/bookings/{booking_id}/cancel")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
# 原本的 BOOKINGS 還在,只是狀態變了
assert len(BOOKINGS) == 1
assert BOOKINGS[0]["status"] == "cancelled"
六個測試覆蓋了寫入端點的三個面向:成功路徑(建立成功)、驗證失敗路徑(缺欄位、格式錯、長度錯)、錯誤處理(刪除不存在的東西 404)。status_code == 422 是 FastAPI/Pydantic 預設的驗證失敗 status code,比 400 更精準地表達「請求格式正確但內容不通過驗證」。204 No Content 刪除慣例,r.text == "" 驗證沒有主體。
把業務邏輯從 endpoint 抽出來
上面的 create_booking 把「驗證、寫入、產生 id、設狀態」全做在一個函式裡;當業務規則變複雜(例如預約衝突檢查、通知排程、稽核日誌)就會爆掉。我們用 service layer 把這些規則集中:
# services.py
# 預約業務邏輯:把「規則」與「傳輸」分開
import uuid
from datetime import datetime, timezone
from main_api import BOOKINGS, BookingIn, BookingOut
class BookingConflict(Exception):
# 自訂例外,給 endpoint 轉成 409 Conflict
pass
def create_booking_service(payload: BookingIn) -> BookingOut:
"""新增預約,含衝突檢查。"""
# 規則 1:同一時段不能有兩個 confirmed 預約
for existing in BOOKINGS:
if (
existing["slot"] == payload.slot
and existing["status"] == "confirmed"
):
raise BookingConflict(f"時段 {payload.slot} 已被預約")
booking = {
"id": str(uuid.uuid4()),
"customer": payload.customer,
"slot": payload.slot,
"service": payload.service,
"status": "confirmed",
"created_at": datetime.now(timezone.utc).timestamp(),
}
BOOKINGS.append(booking)
return BookingOut(**booking)
def cancel_booking_service(booking_id: str) -> BookingOut:
"""取消預約,找不到拋 ValueError。"""
for b in BOOKINGS:
if b["id"] == booking_id:
b["status"] = "cancelled"
return BookingOut(**b)
raise ValueError(f"找不到預約 {booking_id}")
def delete_booking_service(booking_id: str) -> bool:
"""刪除預約,回傳是否真的有刪除。"""
before = len(BOOKINGS)
BOOKINGS[:] = [b for b in BOOKINGS if b["id"] != booking_id]
return len(BOOKINGS) < before
這個 service 層做了三件事:第一,把「同時段不能有兩個 confirmed 預約」這種商業規則集中起來,未來加更多規則(至少提前 X 小時預約、最多 N 筆預約)都在這裡加。第二,自訂 BookingConflict 例外讓 endpoint 可以明確轉成 409。第三,每個函式都回傳 BookingOut 而不是 dict,避免 endpoint 額外轉型。
endpoint 改為呼叫 service 層,自訂例外用 @app.exception_handler 統一處理:
# 改寫 main_api.py 的 endpoint
from fastapi.requests import Request
from fastapi.responses import JSONResponse
from services import (
BookingConflict,
create_booking_service,
cancel_booking_service,
delete_booking_service,
)
@app.exception_handler(BookingConflict)
async def conflict_handler(request: Request, exc: BookingConflict):
return JSONResponse(status_code=409, content={"detail": str(exc)})
@app.post("/api/bookings", response_model=BookingOut, status_code=201)
def create_booking(payload: BookingIn):
return create_booking_service(payload)
@app.delete("/api/bookings/{booking_id}", status_code=204)
def delete_booking_api(booking_id: str):
if not delete_booking_service(booking_id):
raise HTTPException(status_code=404, detail="找不到此預約")
return None
@app.patch("/api/bookings/{booking_id}/cancel", response_model=BookingOut)
def cancel_booking_api(booking_id: str):
try:
return cancel_booking_service(booking_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
把 endpoint 縮短後,每個函式只負責「接 HTTP、丟 service、回應」。未來 service 加上快取(Web Day 20)、日誌(Web Day 22)、權限檢查(Web Day 13)都不會影響 endpoint 的形狀。Web Day 35 之後的「預約管理系統」就是用這套模式擴充。
為 service 層加單元測試
service 層可以用純 Python 測試,不需要 HTTP。這對商業規則(例如衝突檢查)特別有價值:
# test_services.py
# service 層單元測試:純 Python,不走 HTTP
import pytest
from services import (
BookingConflict,
create_booking_service,
cancel_booking_service,
delete_booking_service,
)
from main_api import BOOKINGS, BookingIn
@pytest.fixture(autouse=True)
def reset():
BOOKINGS.clear()
yield
BOOKINGS.clear()
def _make_in(**overrides) -> BookingIn:
defaults = {"customer": "王小明", "slot": "2025-08-10T10:00"}
defaults.update(overrides)
return BookingIn(**defaults)
def test_create_booking_adds_to_list():
booking = create_booking_service(_make_in())
assert booking.customer == "王小明"
assert booking.status == "confirmed"
assert len(BOOKINGS) == 1
def test_create_booking_detects_conflict():
create_booking_service(_make_in(slot="2025-08-10T10:00"))
with pytest.raises(BookingConflict):
create_booking_service(_make_in(slot="2025-08-10T10:00"))
def test_create_booking_cancelled_slot_can_be_reused():
# 取消的預約釋出時段
create_booking_service(_make_in(slot="2025-08-10T10:00"))
cancel_booking_service(BOOKINGS[0]["id"])
# 同時段可以再預約(因為舊的是 cancelled)
booking = create_booking_service(_make_in(slot="2025-08-10T10:00"))
assert booking.status == "confirmed"
assert len(BOOKINGS) == 2
def test_cancel_missing_booking_raises():
with pytest.raises(ValueError):
cancel_booking_service("does-not-exist")
def test_delete_existing_booking():
booking = create_booking_service(_make_in())
assert delete_booking_service(booking.id) is True
assert len(BOOKINGS) == 0
def test_delete_missing_booking():
assert delete_booking_service("does-not-exist") is False
這份測試直接 import service 函式,不需要 FastAPI 的 TestClient,執行速度快(毫秒級)。其中 test_create_booking_detects_conflict 與 test_create_booking_cancelled_slot_can_be_reused 證明了「取消的時段可以重新被預約」這種業務規則。這類規則如果只靠 endpoint 測試覆蓋,容易在重構時漏掉;service 層測試把它們鎖住,未來改程式碼時立刻看到失敗。
日誌與審計
寫入操作應該留紀錄,方便事後追查「誰在什麼時候改了什麼」。Web Day 22 已介紹過 Python logging,這裡把 audit log 直接做進 service 層:
# audit.py
# 預約寫入的稽核日誌
import logging
import os
from datetime import datetime, timezone
audit = logging.getLogger("booking.audit")
def log_action(action: str, booking_id: str, customer: str, **details):
audit.info(
"action=%s booking_id=%s customer=%s %s",
action,
booking_id,
customer,
" ".join(f"{k}={v}" for k, v in details.items()),
)
# 在 services.py 裡的呼叫範例:
# log_action("create", booking["id"], booking["customer"], slot=booking["slot"])
# log_action("cancel", booking_id, BOOKINGS_BY_ID[booking_id]["customer"])
# log_action("delete", booking_id, "-")
在 services.py 的關鍵節點加 log_action(...),所有寫入都會留下 action / booking_id / customer / 時間戳記 的記錄。Web Day 22 會教如何把這些紀錄送到檔案、Elasticsearch、或 Loki。今天先建立結構,日誌輸出由 Day 22 的 logging 設定統一處理。
實務上 audit log 跟一般 log 應該分開:audit log 寫進不可竄改的儲存(append-only file、資料庫的 audit table)、保留期長(至少一年)、只記錄「誰做了什麼」而非系統除錯訊息。今天先建函式,後續章節會補上儲存與查詢介面。
表單使用者體驗的細節
前面專注在「功能面」,但表單的使用者體驗同樣重要。我們整理出三個常被忽略的細節。第一,送出後自動重置欄位。使用者新增一筆預約後,預期看到表單清空、可以繼續新增下一筆。我們在 Server Action 成功後用 formRef.current?.reset() 處理(client component 才能存取 DOM),這需要把 useRef 與 useEffect 串起來。簡化版的替代做法是 redirect("/bookings") 把頁面整個重新整理。
第二,顯示剛剛送出的內容。使用者最常問「我剛剛送的那筆呢?」。我們在 Server Action 成功後把 booking id 存進 state.message,前端顯示「已新增預約 #abc123」,使用者能立刻確認。
第三,送出中禁用整個表單。今天的 SubmitButton 用了 disabled={pending},但其他欄位仍可編輯。實務上建議把整個表單套上一層 aria-busy={pending},讓螢幕閱讀器與鍵盤使用者知道「現在不能動」。useFormStatus 只給 submit 按鈕用,要禁用其他欄位可以用另一個 useFormStatus 放在 form 內的 wrapper 元件。
第四,錯誤訊息要可行動。今天的錯誤訊息(「請輸入客戶姓名」)明確指出要修正的欄位;如果改成「驗證失敗」就完全不夠。對應原則:錯誤訊息要回答「哪裡錯了」與「該怎麼修正」,例如「客戶姓名至少 1 個字」比「姓名不能空白」更直接。
取消預約的 Server Action
新增的 Server Action 只示範了 POST。實務上取消預約也是常用操作,我們用同一套模式寫:
// 在 src/app/bookings/actions.ts 加上
export async function cancelBookingAction(bookingId: string) {
const res = await fetch(`${API_BASE}/api/bookings/${bookingId}/cancel`, {
method: "PATCH",
});
if (!res.ok) {
throw new Error(`取消失敗:${res.status}`);
}
revalidatePath("/bookings");
revalidatePath(`/bookings/${bookingId}`);
}
取消動作沒有表單欄位,所以這個 action 只接收 bookingId 一個參數。revalidatePath 同時刷新清單頁與細節頁,確保兩個頁面都看到最新狀態。PATCH 用來表達「部分更新」語意(這裡只改 status 欄位),跟 PUT(整個資源替換)做區分。
在細節頁用一個「取消」按鈕觸發這個 action:
// src/app/bookings/[id]/cancel-button.tsx
// 取消按鈕(client component)
"use client";
import { cancelBookingAction } from "../actions";
export function CancelButton({ bookingId }: { bookingId: string }) {
return (
<form
action={async () => {
await cancelBookingAction(bookingId);
}}
>
<button type="submit">取消預約</button>
</form>
);
}
這個範例把 cancelBookingAction 包進 <form action={...}> 的 inline async 函式,是 React 19 的新寫法。送出後自動跳轉(如果 action 內 redirect())或停留在原頁(如果只有 revalidatePath),由 action 內部決定。
用 CLI 端到端驗證寫入
pytest 跑完後,我們會用一支 CLI 腳本做「完整端到端測試」:從「沒資料」開始、建立預約、列出確認、刪除、再次列出確認。這比單元測試更貼近真實使用情境:
# scripts/e2e_write.py
# 端到端測試:新增 → 列出 → 刪除 → 再次列出
import httpx
BASE = "http://127.0.0.1:8000"
def main() -> int:
with httpx.Client(base_url=BASE, timeout=5.0) as client:
# 新增
r = client.post(
"/api/bookings",
json={"customer": "E2E 測試", "slot": "2025-08-10T10:00"},
)
r.raise_for_status()
booking_id = r.json()["id"]
print(f"已建立:{booking_id}")
# 列出確認
r = client.get("/api/bookings")
total = r.json()["total"]
assert total >= 1
print(f"清單共 {total} 筆")
# 取消(用 PATCH)
r = client.patch(f"/api/bookings/{booking_id}/cancel")
r.raise_for_status()
assert r.json()["status"] == "cancelled"
print(f"已取消:{booking_id}")
# 刪除
r = client.delete(f"/api/bookings/{booking_id}")
assert r.status_code == 204
print(f"已刪除:{booking_id}")
print("端到端測試通過")
return 0
if __name__ == "__main__":
raise SystemExit(main())
這支腳本可以在 CI 跑(先啟動 FastAPI、跑 pytest、跑 e2e、關 FastAPI)。它驗證的不是「單元邏輯」而是「整個 API 串起來能不能運作」,跟整合測試互補。
常見錯誤與踩雷
第一個常見踩雷:Server Action 忘了加 "use server"。如果檔案頂端少了這行,函式會被當成普通函式(client 端執行),找不到 FastAPI 的 URL、找不到環境變數,瀏覽器 console 會出現「process is not defined」。對應排查:每個 actions.ts 檔案第一行都應該有 "use server"。
第二個常見踩雷:把 "use server" 加在 client component 裡。React 19 看到 client component 裡有 "use server" 會編譯錯誤,因為 client 端不能直接呼叫 server function。對應排查:"use server" 只放在專門的 actions 檔案,每個函式都會被序列化送到伺服器執行。
第三個常見踩雷:revalidatePath 沒生效。Server Action 寫入後頁面沒更新,多半是因為忘了 revalidatePath("/bookings"),Next.js 繼續用快取的舊資料。對應排查:每次寫入都呼叫對應的 revalidatePath;如果想更精準(只重抓特定頁面區塊),改用 revalidateTag("bookings")。
第四個常見踩雷:表單送出後 URL 沒變。使用者按重新整理瀏覽器會跳出「確認重新送出表單」對話框,因為瀏覽器以為你要重送 POST。對應策略:Server Action 成功後用 redirect() 把使用者導到 GET 頁面。今天的範例省略了,但實務上建議加上。
第五個常見踩雷:useFormStatus 用錯位置。useFormStatus 只能在 <form> 內部使用,必須是 <form> 的後代元件。今天把 SubmitButton 拆成獨立元件、放在 <form> 內,正是為了讓 useFormStatus() 找得到它。如果把 SubmitButton 放在 <form> 外面,pending 永遠是 false。
效能與實務提醒
Server Action 在主網路架構下走 POST 表單機制,不是 JSON API。每次送出都會把整個 form data 序列化後送到伺服器。對小表單沒問題,但對大資料(例如 50 個欄位)就要考慮:是否改用 client component 直接呼叫 fetch,把資料用 JSON 傳遞。Web Day 36 進入「認證與多角色」時,會處理「表單內容包含敏感資料怎麼加密」的問題。
另一個提醒是「前端驗證不能取代後端驗證」。今天的 zod 驗證能擋掉使用者輸入錯,但擋不掉有人用 curl 直接打 FastAPI(繞過前端)。後端的 Pydantic 驗證才是真正的最後防線。對應原則:前端驗證做 UX、後端驗證做安全,兩者都要寫。
最後一個細節:useActionState 是 React 19 才有的 hook。如果你用舊版 React 或忘了升級,會出現 useActionState is not a function 的錯誤。本系列固定在 React 19,記得檢查 package.json 的 react 與 react-dom 版本。
小結
今天把「Next.js 15 寫入 FastAPI」的最小流程走完。後端加了 POST / DELETE / PATCH 三個端點(含 Pydantic 驗證),前端用 zod 做表單驗證、用 Server Action 串接後端、用 useActionState 與 useFormStatus 處理狀態、用 pytest 與 e2e 腳本驗證。整個寫入流程對使用者來說是「填表單→按送出→看到結果」,對開發者來說是「定義 schema→寫 action→接 form→完成」。明天我們要進入認證:token 怎麼存、自動登出怎麼做、CSRF 怎麼擋,這是「前端整合」這個區塊的關鍵安全章節。
結語
今天的重點是「React 19 Server Actions + FastAPI 寫入端點」。我們用 zod 在前端做表單驗證,用 Server Action 把 fetch 包起來,用 useActionState 與 useFormStatus 處理送出狀態,用 revalidatePath 重抓清單頁。讀完這篇你應該能回答:React 19 的 Server Action 怎麼寫?useActionState 與 useFormStatus 的差別?zod 與 Pydantic 在表單驗證上如何分工?FastAPI 的 Pydantic 怎麼回傳 422 與 201?
明天,我們進入「前端認證」。Web Day 12 已教過後端 JWT(簽發與驗證 token),今天要把這套機制整合到 Next.js:token 存哪裡(localStorage vs httpOnly cookie 的取捨要講清楚)、怎麼自動帶上每個請求、token 過期怎麼自動登出。這是「對外頁」能不能正式上線的關卡,請預留一段完整的時間跟著做。
延伸資源
- React 19 Server Actions(2025):
react.dev/reference/rsc/server-actions,"use server"、useActionState、revalidatePath的現行用法。 - Next.js 15 Server Actions 與 revalidatePath(2025-07):
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations,完整的 cache 失效策略。 - Pydantic v2 field_validator(2.11,2025):
https://docs.pydantic.dev/latest/concepts/validators/,自訂驗證、Field 約束、422 錯誤結構。 - zod 4 官方 Docs(2025):
https://zod.dev/,safeParse、refine、z.infer的現行用法。 - FastAPI 0.116 status code 慣例(2025):
https://fastapi.tiangolo.com/tutorial/response-status-code/,201、204、404、422 的使用時機。
留言
張貼留言