Web Day 25 用 Next.js 串接 FastAPI(一)讀取
執行需求:CPU 可跑。前兩天用 HTMX 2.0 把「伺服器回 HTML」這條路走到能動,今天切到另一條路:用 Next.js 15(React 19)做前端,FastAPI 負責資料 API。我們只做最小串接——讀取清單、讀取單筆、錯誤處理、載入狀態;表單寫入留給明天。這樣的拆分讓今天的內容能在一個下午跑完,也讓後面 Web Day 27(認證)、Web Day 28(WebSocket)有乾淨的基礎。
引言
Web Day 23 我們比較了 HTMX 與 SPA 的成本結構,Web Day 24 把 HTMX 實作完整。今天換到 Next.js 15,目標是「用最小的前端程式碼從 FastAPI 讀資料並顯示」。Next.js 15 的 App Router 預設用 React Server Components(RSC):fetch 發生在伺服器端、HTML 直接渲染給瀏覽器、SEO 自動友善;只有需要互動的元件才標 "use client",讓前端 bundle 維持小體積。
這篇文章的目標是讓你看到 Next.js 15 串接 FastAPI 的「最小可行路徑」:建立 Next.js 專案、設定環境變數、用 Server Components 抓 FastAPI 的 JSON、顯示清單與單頁細節、用 loading.tsx 與 error.tsx 處理邊界狀態。我們刻意不碰 React state、React Hook Form、客戶端快取這些進階主題,把範圍控制在「讀取」。明天(Web Day 26)才進入「寫入與表單」。貫穿專案「預約管理系統」的對外頁會用今天示範的模式建立。
環境與專案結構
假設你已經有 FastAPI 服務跑在 http://127.0.0.1:8000(沿用 Web Day 24 的 main.py)。今天我們在另一個目錄建立 Next.js 專案。先看完整環境需求:
| 工具 | 版本 | 用途 |
|---|---|---|
| Node.js | 20.x 或更新 | 執行 Next.js 工具鏈 |
| Next.js | 15.0.x(2025 年 7 月穩定) | React 框架 |
| React | 19.0.x | UI 函式庫 |
| TypeScript | 5.5 以上 | 型別檢查(推薦) |
安裝指令(沿用 Web Day 2 的 uv 精神,這裡用 npm 是因為 Next.js 工具鏈原生支援):
# 建立 Next.js 15 專案(非互動模式,App Router + TypeScript)
npx create-next-app@15.0.3 booking-web \
--typescript --eslint --app --no-tailwind --src-dir --import-alias "@/*"
cd booking-web
# 啟動開發伺服器
npm run dev
# 輸出(範例):
# Next.js 15.0.3
# - Local: http://localhost:3000
# - Network: http://192.168.x.x:3000
# Ready in 1.2s
--no-tailwind 是為了把 CSS 留給讀者自己接設計系統;--src-dir 把程式碼放進 src/,符合常見後端工程師的目錄直覺。--import-alias "@/*" 之後可以用 @/lib/api 引入模組,不用寫相對路徑。版本 15.0.3 是 2025 年 7 月的穩定釋出;如果你看到更新的小版本號碼(例如 15.0.4),通常也能直接用。
後端:把 Day 24 的 HTMX 端點補上 JSON 變體
Web Day 24 的後端回的是 HTML 片段,今天我們在同一支 FastAPI 程式裡加上 JSON 端點,讓 Next.js 可以直接 fetch。這種「同一個後端、兩種前端」的模式很常見:HTMX 後台對內、Next.js 對外,後端共用 service 層與資料模型。完整檔案結構如下:
# main_api.py
# FastAPI:給 Next.js 用的 JSON API(同檔共存 Day 24 的 HTMX 端點)
import uuid
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
app = FastAPI(title="預約 API", version="0.1.0")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
BOOKINGS: list[dict] = []
class BookingOut(BaseModel):
id: str
customer: str
slot: str
service: str
status: str
created_at: float
class BookingList(BaseModel):
items: list[BookingOut]
total: int = Field(default=0)
def _now_ts() -> float:
return datetime.now(timezone.utc).timestamp()
@app.get("/api/bookings", response_model=BookingList)
def list_bookings(
status: str | None = Query(default=None),
q: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
rows = BOOKINGS
if status:
rows = [b for b in rows if b["status"] == status]
if q:
rows = [b for b in rows if q.lower() in b["customer"].lower()]
total = len(rows)
rows = rows[offset : offset + limit]
return BookingList(items=[BookingOut(**b) for b in rows], total=total)
@app.get("/api/bookings/{booking_id}", response_model=BookingOut)
def get_booking(booking_id: str):
for b in BOOKINGS:
if b["id"] == booking_id:
return BookingOut(**b)
raise HTTPException(status_code=404, detail="找不到此預約")
這份檔案比 Web Day 24 的 main.py 多兩件事:第一,用 Pydantic BaseModel 定義 BookingOut 與 BookingList,讓 FastAPI 自動產生 JSON Schema;Swagger UI 看到的就是它。第二,加上分頁參數 limit 與 offset(用 Query(default, ge=, le=) 做範圍檢查),符合 REST API 的慣例。response_model 會自動過濾欄位、把時間戳記轉成 ISO 8601 字串。raise HTTPException(404) 是 FastAPI 處理「找不到資源」的標準寫法,會自動轉成 404 JSON 回應。
順手把 Day 24 的範例資料庫擴充一下,新增幾筆假資料讓前端有東西可看:
# seed.py
# 預約資料種子(執行:uvicorn main_api:app --port 8000 後跑一次)
import uuid
from datetime import datetime, timedelta, timezone
from main_api import app, BOOKINGS
SAMPLES = [
("王小明", "重訓", 1),
("李小華", "瑜珈", 2),
("張大同", "拳击", 3),
("陳小芳", "重訓", 5),
]
now = datetime.now(timezone.utc)
BOOKINGS.clear()
for i, (customer, service, days_later) in enumerate(SAMPLES):
slot = (now + timedelta(days=days_later, hours=10)).isoformat()
BOOKINGS.append({
"id": str(uuid.uuid4()),
"customer": customer,
"slot": slot,
"service": service,
"status": "confirmed",
"created_at": now.timestamp() + i,
})
print(f"已建立 {len(BOOKINGS)} 筆範例預約")
# 輸出:已建立 4 筆範例預約
種子資料在開發階段很有用,避免每次重啟都看到空清單。datetime.now(timezone.utc) 確保時區正確;timedelta(days=, hours=) 設定未來的預約時間。created_at 用遞增的浮點數當 ID 排序依據,方便之後做差集查詢(Day 24 已示範過)。
前端:Next.js 15 串接 FastAPI
接下來建立前端專案結構。Next.js 15 App Router 的慣例是把每個路由放進 src/app/ 下的目錄:
booking-web/
├── src/
│ ├── app/
│ │ ├── layout.tsx # 根 layout
│ │ ├── page.tsx # 首頁(重導到 /bookings)
│ │ ├── bookings/
│ │ │ ├── page.tsx # 預約清單
│ │ │ ├── loading.tsx # 載入狀態
│ │ │ ├── error.tsx # 錯誤狀態
│ │ │ └── [id]/
│ │ │ └── page.tsx # 預約細節
│ │ └── not-found.tsx # 404 頁
│ └── lib/
│ └── api.ts # 集中管理 fetch
├── .env.local # 環境變數(不入版控)
├── next.config.ts
└── package.json
這個結構的重點:loading.tsx 與 error.tsx 是約定檔名,Next.js 會自動在對應的 server component 進入等待或失敗時顯示;[id]/page.tsx 用動態路由做單筆細節頁;lib/api.ts 集中放 fetch 邏輯,避免每個頁面都寫一遍 URL。
先看 lib/api.ts,把所有 fetch 包成有型別的函式:
// src/lib/api.ts
// 集中管理 FastAPI 呼叫,所有 fetch 經過這裡
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://127.0.0.1:8000";
export type Booking = {
id: string;
customer: string;
slot: string;
service: string;
status: string;
created_at: number;
};
export type BookingList = {
items: Booking[];
total: number;
};
export async function listBookings(params: {
status?: string;
q?: string;
limit?: number;
offset?: number;
} = {}): Promise<BookingList> {
const url = new URL("/api/bookings", BASE);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
}
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`取得預約清單失敗:${res.status}`);
return res.json();
}
export async function getBooking(id: string): Promise<Booking> {
const res = await fetch(new URL(`/api/bookings/${id}`, BASE), {
cache: "no-store",
});
if (res.status === 404) throw new Error("找不到此預約");
if (!res.ok) throw new Error(`取得預約細節失敗:${res.status}`);
return res.json();
}
這個檔案做了幾件事:第一,process.env.NEXT_PUBLIC_API_BASE 讀環境變數,沒設定時 fallback 到本機 FastAPI。NEXT_PUBLIC_ 前綴讓 Next.js 把這個變數注入到瀏覽器端(如果變數沒有這個前綴,只在伺服器端可用)。第二,cache: "no-store" 告訴 Next.js 不要快取這個請求——預約資料每次都要拿最新的,避免看到過期的預約。第三,把 404 與其他錯誤分開處理:404 表示「資料不存在」,其他錯誤表示「伺服器出問題」,UI 可以給不同訊息。
接下來是清單頁 app/bookings/page.tsx,這是 React Server Component(沒有 "use client"):
// src/app/bookings/page.tsx
// 預約清單(Server Component,直接在伺服器 fetch)
import Link from "next/link";
import { listBookings } from "@/lib/api";
export const dynamic = "force-dynamic"; // 永遠重新抓取,不做 SSG 快取
export default async function BookingsPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; q?: string }>;
}) {
const sp = await searchParams;
const data = await listBookings({
status: sp.status,
q: sp.q,
limit: 20,
});
return (
<main style={{ padding: 24 }}>
<h1>預約清單</h1>
<form method="get">
<label>狀態
<select name="status" defaultValue={sp.status ?? "all"}>
<option value="all">全部</option>
<option value="confirmed">已確認</option>
<option value="cancelled">已取消</option>
</select>
</label>
<label>關鍵字
<input name="q" defaultValue={sp.q ?? ""} placeholder="客戶姓名" />
</label>
<button type="submit">篩選</button>
</form>
<p>共 {data.total} 筆,目前顯示前 {data.items.length} 筆</p>
<ul>
{data.items.map((b) => (
<li key={b.id}>
<Link href={`/bookings/${b.id}`}>
{b.customer}|{b.service}|{b.slot}
</Link>
</li>
))}
</ul>
</main>
);
}
這是典型的 React Server Component。沒有 "use client"、沒有 useState、沒有 useEffect,直接在 async function 裡 await listBookings(),fetch 發生在伺服器端、HTML 已經包含資料、再送到瀏覽器。export const dynamic = "force-dynamic" 告訴 Next.js「不要做靜態預先產生,每次請求都重新抓取」——因為預約資料是動態的,這個設定保證使用者看到的永遠是最新狀態。
注意 searchParams 在 Next.js 15 是 Promise(這是 15 版的新規定),要先 await 才能用。<form method="get"> 是傳統 HTML 表單(這裡沒用 HTMX,所以用瀏覽器原生送出),按「篩選」會把欄位做成 URL query string,瀏覽器重新整理頁面、Next.js 收到新的 searchParams、重新 fetch。
單筆細節頁 app/bookings/[id]/page.tsx 用動態路由:
// src/app/bookings/[id]/page.tsx
// 預約細節(Server Component)
import Link from "next/link";
import { getBooking } from "@/lib/api";
export const dynamic = "force-dynamic";
export default async function BookingDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const booking = await getBooking(id);
return (
<main style={{ padding: 24 }}>
<Link href="/bookings">← 回到清單</Link>
<h1>預約細節</h1>
<dl>
<dt>客戶</dt><dd>{booking.customer}</dd>
<dt>時段</dt><dd>{booking.slot}</dd>
<dt>服務</dt><dd>{booking.service}</dd>
<dt>狀態</dt><dd>{booking.status}</dd>
</dl>
</main>
);
}
動態路由的 params 在 Next.js 15 也是 Promise,要 await 後再解構。getBooking() 會丟出例外(404 或其他錯誤),由 error.tsx 或 not-found.tsx 接住。
處理載入與錯誤狀態
Next.js 15 App Router 用約定檔名做狀態邊界。loading.tsx 在 server component 進入等待時自動顯示,error.tsx 在拋出例外時顯示:
// src/app/bookings/loading.tsx
// 預約清單載入中
export default function Loading() {
return (
<main style={{ padding: 24 }}>
<p>正在載入預約清單...</p>
</main>
);
}
// src/app/bookings/error.tsx
// 預約清單錯誤(必須是 client component)
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main style={{ padding: 24 }}>
<h1>載入失敗</h1>
<p>{error.message}</p>
<button onClick={reset}>重試</button>
</main>
);
}
loading.tsx 會在 server component fetch 期間顯示,瀏覽器立刻看到骨架而不是空白頁。error.tsx 必須是 client component(因為 reset() 是互動函式)。Next.js 15 把這個檔案當成 error boundary:server component 拋出例外時,最接近的 error.tsx 接手、顯示錯誤訊息、提供「重試」按鈕(呼叫 reset() 重新執行 server component)。
404 的情況用 not-found.tsx 處理,這是另一個約定檔名:
// src/app/not-found.tsx
// 整站 404
import Link from "next/link";
export default function NotFound() {
return (
<main style={{ padding: 24 }}>
<h1>找不到頁面</h1>
<Link href="/bookings">回到預約清單</Link>
</main>
);
}
要在細節頁主動觸發 404,在 server component 裡拋 notFound():
// src/app/bookings/[id]/page.tsx(修訂)
import { notFound } from "next/navigation";
import { getBooking } from "@/lib/api";
export default async function BookingDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let booking;
try {
booking = await getBooking(id);
} catch (e) {
if (e instanceof Error && e.message === "找不到此預約") {
notFound(); // 觸發 not-found.tsx
}
throw e;
}
return (
<main style={{ padding: 24 }}>
<h1>{booking.customer}</h1>
{/* ... */}
</main>
);
}
notFound() 是 Next.js 內建函式,呼叫後 Next.js 會渲染最近的 not-found.tsx 並回應 HTTP 404(對 SEO 友善,搜尋引擎知道這個 URL 真的不存在)。這個模式比丟 HTTPException 更乾淨,因為 HTTP 例外在 server component 會被 error.tsx 接住,無法表達「404」這個語意。
用 pytest 驗證 JSON API
Web Day 24 的測試是針對 HTMX 端點,今天要為 JSON API 寫一份獨立的測試。模式相同(pytest + TestClient),斷言目標換成 JSON 結構:
# test_api.py
# FastAPI JSON API 的 pytest 測試
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 _seed():
BOOKINGS.append({
"id": "id-1", "customer": "王小明", "slot": "2025-08-10T10:00",
"service": "重訓", "status": "confirmed", "created_at": 1.0,
})
BOOKINGS.append({
"id": "id-2", "customer": "李小華", "slot": "2025-08-10T11:00",
"service": "瑜珈", "status": "cancelled", "created_at": 2.0,
})
def test_list_bookings_default():
_seed()
r = client.get("/api/bookings")
assert r.status_code == 200
data = r.json()
assert data["total"] == 2
assert len(data["items"]) == 2
assert data["items"][0]["customer"] == "王小明"
def test_list_bookings_filter_by_status():
_seed()
r = client.get("/api/bookings", params={"status": "confirmed"})
assert r.status_code == 200
data = r.json()
assert data["total"] == 1
assert data["items"][0]["status"] == "confirmed"
def test_list_bookings_search_by_keyword():
_seed()
r = client.get("/api/bookings", params={"q": "王"})
assert r.status_code == 200
assert r.json()["total"] == 1
def test_list_bookings_pagination():
_seed()
r = client.get("/api/bookings", params={"limit": 1, "offset": 0})
assert r.json()["items"][0]["id"] == "id-1"
r = client.get("/api/bookings", params={"limit": 1, "offset": 1})
assert r.json()["items"][0]["id"] == "id-2"
def test_get_booking_success():
_seed()
r = client.get("/api/bookings/id-1")
assert r.status_code == 200
assert r.json()["customer"] == "王小明"
def test_get_booking_404():
r = client.get("/api/bookings/does-not-exist")
assert r.status_code == 404
assert "detail" in r.json()
這份測試展示六種 JSON API 的常見斷言:預設清單、篩選、關鍵字搜尋、分頁、單筆成功、單筆 404。params= 是 httpx 與 TestClient 共通的寫法,避免手動拼 query string。_seed() 是測試內部的輔助函式,把兩筆假資料塞進 BOOKINGS,每個測試前 autouse fixture 都會清空,避免互相干擾。
實務上我們會把 _seed() 抽成共享 fixture 放到 conftest.py,讓其他測試檔也能用:
# conftest.py
# 共用 fixture:清空資料、種子資料、test client
import pytest
from fastapi.testclient import TestClient
from main_api import app, BOOKINGS
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture(autouse=True)
def reset_bookings():
BOOKINGS.clear()
yield
BOOKINGS.clear()
@pytest.fixture
def two_bookings():
BOOKINGS.append({
"id": "id-1", "customer": "王小明", "slot": "2025-08-10T10:00",
"service": "重訓", "status": "confirmed", "created_at": 1.0,
})
BOOKINGS.append({
"id": "id-2", "customer": "李小華", "slot": "2025-08-10T11:00",
"service": "瑜珈", "status": "cancelled", "created_at": 2.0,
})
return BOOKINGS
@pytest.fixture
def api_base():
return "http://127.0.0.1:8000"
把 fixture 集中有兩個好處:第一,測試本體更短、更專注於斷言邏輯;第二,新增測試時直接 def test_x(client, two_bookings) 就能拿到乾淨的環境,不用每個檔案都重寫樣板。Web Day 17 對 fixture 管理有完整示範。
用 httpx 做整合測試(連到真實運行的伺服器)也是常用的驗證手段:
# test_integration.py
# 用 httpx 打到真實運行的 FastAPI(啟動方式:uvicorn main_api:app --port 8000)
import httpx
def test_api_responds(api_base):
with httpx.Client(base_url=api_base, timeout=5.0) as client:
r = client.get("/api/bookings")
assert r.status_code == 200
assert "items" in r.json()
assert "total" in r.json()
print(f"預約 API 回應:{r.json()['total']} 筆")
# 輸出(範例):預約 API 回應:0 筆
def test_pagination(api_base):
with httpx.Client(base_url=api_base, timeout=5.0) as client:
r = client.get("/api/bookings", params={"limit": 5, "offset": 0})
assert r.status_code == 200
body = r.json()
assert len(body["items"]) <= 5
assert body["total"] >= 0
這份測試要求 FastAPI 已經啟動(CI 通常會用 uvicorn main_api:app --port 8000 & 在背景啟動、跑完測試再關閉)。好處是它驗證了「真實 HTTP 行為」,包含路由匹配、序列化、status code 都跟線上一致;壞處是速度比 TestClient 慢、需要管理生命週期。兩種測試建議都寫:TestClient 抓單元邏輯,httpx 抓整合行為。
用 Python CLI 驗證整個流程
部署到正式環境前,我們會用一支 CLI 腳本「從另一個 process 驗證 API」。這支腳本可以塞進 CI、可以塞進 health check,也可以讓營運同事手動跑:
# scripts/check_api.py
# 用 CLI 驗證 /api/bookings 與 /api/bookings/{id} 都健康
import sys
import httpx
BASE = "http://127.0.0.1:8000"
def check_list() -> int:
r = httpx.get(f"{BASE}/api/bookings", timeout=5.0)
r.raise_for_status()
body = r.json()
print(f"GET /api/bookings:{body['total']} 筆")
return body["total"]
def check_one(booking_id: str) -> bool:
r = httpx.get(f"{BASE}/api/bookings/{booking_id}", timeout=5.0)
if r.status_code == 200:
print(f"GET /api/bookings/{booking_id}:找到 {r.json()['customer']}")
return True
print(f"GET /api/bookings/{booking_id}:{r.status_code}(略過)")
return False
if __name__ == "__main__":
total = check_list()
if total == 0:
print("沒有資料,先跑一次 seed.py")
sys.exit(1)
ids = httpx.get(f"{BASE}/api/bookings", timeout=5.0).json()["items"]
for b in ids:
check_one(b["id"])
print("所有檢查通過")
# 輸出:所有檢查通過
這支腳本展示三個工程細節:用 httpx.get() 的簡寫做單次呼叫、用 raise_for_status() 把 4xx/5xx 變成例外、用 sys.exit(1) 告訴 CI「這次檢查失敗」。把它接到 package.json 的 "check": "python tools/check_api.py",或 GitHub Actions 的 health check job(Web Day 32 展開),就能在每次部署後自動驗證 API 還活著。
分頁、篩選與排序的策略
今天的 JSON API 用的是「offset / limit」分頁,這是最直觀但不是最高效的做法。當資料量成長到數萬筆,前端一直往後翻頁時,offset 查詢會愈來愈慢(資料庫需要掃描 offset + limit 筆才丟棄前 offset 筆)。更好的做法是「cursor-based pagination」:用 created_at 或 id 作為 cursor,客戶端傳「我要比這個 cursor 新的/更舊的」,伺服器用索引範圍掃描而非 skip。
貫穿專案「預約管理系統」在 Web Day 35 開始會用 SQLModel 接 PostgreSQL,分頁那時會改用 cursor 模式(?after=id-xxx&limit=20)。今天的範例先用 offset 是因為它最容易理解、也最容易用 curl 驗證;正式上線前要記得評估資料量級,決定是否換成 cursor。
另一個設計選擇是「篩選與排序放前端還是後端」。我們目前的做法是「後端做篩選、不做排序」,預設按插入順序回傳。如果未來需要「依預約時間排序」「依客戶姓名排序」,最簡單的擴充是在 list_bookings() 加上 sort 參數(sort=slot、sort=customer),但要注意 SQL Injection(Web Day 15 展開);如果想完全在前端排,就維持後端固定回傳順序、由 React 端的 Array.sort() 處理。
最後一個策略提醒:API 端點設計要考慮「未來分版」。今天我們用 /api/bookings,明天可能需要 /api/v2/bookings(加上預約衝突檢查、回傳更多欄位)。在 URL 加上版本前綴(/api/v1/bookings)雖然會讓今天的範例多幾個字元,但未來演進時舊客戶端不會被強制升級。FastAPI 0.116 支援 APIRouter(prefix="/api/v1"),之後只要把所有 router 換前綴就能平滑升級。
把 Next.js 與 FastAPI 串起來:常見的整合細節
前面講了 Server Components 為什麼不需要 CORS,但實務上還是有幾個整合細節容易踩雷。第一,環境變數要同步:本地開發用 http://127.0.0.1:8000、正式環境用 https://api.example.com。.env.local 放本地值,正式環境在部署平台(如 Vercel、Zeabur、自架 K8s)設定 NEXT_PUBLIC_API_BASE 環境變數;這樣同一支程式碼可以在不同環境跑,不需要改檔案。
第二,time zone 統一用 UTC。FastAPI 端用 datetime.now(timezone.utc) 寫入、序列化後變 ISO 8601 字串(尾端有 +00:00 或 Z);Next.js 端用 new Date(booking.slot) 解析、用 toLocaleString("zh-TW", { timeZone: "Asia/Taipei" }) 顯示。如果前後端混用不同時區,使用者會看到「預約時間差 8 小時」的靈異現象。
第三,錯誤格式要一致。FastAPI 拋 HTTPException 時,預設回應是 {"detail": "..."}。Next.js 端要對應處理,例如 if (!res.ok) throw new Error(res.json().detail),讓 UI 拿到清楚的錯誤訊息。今天的 lib/api.ts 用了簡化版(只用 res.status),正式版本建議加上 JSON 解析。
第四,資料形狀要驗證。FastAPI 用 Pydantic 自動驗證請求與回應,Next.js 端沒有對應的執行期驗證。如果 FastAPI 改了欄位(例如把 customer 改成 customer_name),Next.js 不會立刻壞,會默默顯示 undefined。對應策略:在 lib/api.ts 用 zod 做執行期驗證,把伺服器回應 parse 一次,欄位不符就丟錯誤。Web Day 26 進入表單時,zod 也會用於前端驗證。
常見錯誤與踩雷
第一個常見踩雷:忘記設 NEXT_PUBLIC_API_BASE。Next.js 預設透過 .env.local 讀環境變數,但只有在 npm run dev 啟動時才讀。如果你啟動後才新增 .env.local,要重新啟動 dev server。建立 .env.local 並寫入:
# booking-web/.env.local
NEXT_PUBLIC_API_BASE=http://127.0.0.1:8000
第二個常見踩雷:fetch 在 server component 預設會被 Next.js 快取。即使你在 fetch() 裡寫 cache: "no-store",只要呼叫包進另一個函式(例如 listBookings()),有時會失去這個語意。對應排查:用 export const dynamic = "force-dynamic" 在 page 檔案頂端明確標記,或在 fetch() 呼叫上明確帶 next: { revalidate: 0 }。
第三個常見踩雷:CORS 沒設好。雖然 Next.js Server Components 是在 Node.js 端 fetch(不算瀏覽器 CORS),但 "use client" 元件裡的 fetch 就會遇到。今天的範例都是 Server Components,理論上不需 CORS;但如果你之後加了 client 端的「即時搜尋」功能,要在 FastAPI 端用 CORSMiddleware(allow_origins=["http://localhost:3000"]) 開啟瀏覽器端 CORS。
第四個常見踩雷:忘了 await searchParams。Next.js 15 把 searchParams 改成 Promise,這是為了未來支援 streaming 渲染。如果你看到「params is a Promise」的型別錯誤或「searchParams.map is not a function」這種訊息,就是忘了 await。
第五個常見踩雷:JSON 時間戳記變成字串後忘了轉。FastAPI 的 Pydantic 預設會把 float 轉成數字、datetime 轉成 ISO 8601 字串,所以 created_at 在 JSON 是數字、slot 是字串。前端顯示 slot 時可以直接印;要做時間運算(例如「距今幾小時」)才需要用 new Date(booking.slot) 轉成 Date 物件。
效能與實務提醒
Server Components 的 fetch 在伺服器端執行,能享受 Next.js 內建的 request memoization:同一個請求週期內,同一個 URL 不會被打兩次。這對「頁面同時呼叫 listBookings() 與 getBooking(id)」這類情境很省事。但這個快取只在單次 server render 內有效,跨請求不會保留。如果你的資料需要跨請求快取(例如 60 秒內不變的設定),用 fetch(url, { next: { revalidate: 60 } }) 設定 ISR(Incremental Static Regeneration)。
另一個提醒:"use client" 要用在最小範圍。今天的範例只有 error.tsx 是 client,其他都是 server。Server Component 把 fetch、格式化、組 HTML 都在伺服器做完,瀏覽器只收到一包純文字,沒有大 JS bundle 需要下載。這對「預約系統對外頁」這類需要 SEO、低頻互動的場景特別划算。Web Day 26 開始加入表單,我們會需要更多 "use client",但依然要把 client 範圍壓在最小。
最後一個提醒:Server Component 不能呼叫瀏覽器 API(window、localStorage、document)。如果你在 Server Component 裡用到這些,Next.js 編譯時會報錯「window is not defined」。這限制的解法是:把需要瀏覽器 API 的部分包進 client component;Server Component 只負責資料與 HTML 結構。
小結
今天把「Next.js 15 串接 FastAPI 讀取資料」的最小可行路徑走完。我們在後端加了 Pydantic 模型與分頁參數,建立 lib/api.ts 集中 fetch 邏輯,用 Server Component 寫了清單頁與細節頁,用 loading.tsx、error.tsx、not-found.tsx 處理邊界狀態,用 pytest 為 JSON API 寫了完整測試。整個前端 bundle 在 npm run build 後大約 100 KB(純 server component 時幾乎沒有 JS),符合「對外頁輕量」的需求。明天(Web Day 26)我們會進入「寫入」:用 Server Actions 或 client component 表單做新增預約,並處理表單驗證與錯誤訊息。今天先把讀取走順,明天把互動加上來。
結語
今天的重點是「用最小的 Next.js 15 程式碼讀取 FastAPI JSON」。我們建立了 lib/api.ts 集中 fetch、用 Server Component 渲染清單與細節、用約定檔名處理邊界狀態、用 pytest 驗證後端 JSON API。讀完這篇你應該能回答:Next.js 15 App Router 的 Server Component 怎麼從 FastAPI 拿資料?searchParams 為什麼是 Promise?loading.tsx、error.tsx、not-found.tsx 怎麼用?FastAPI 的 Pydantic 模型對前端有什麼好處?
明天,我們會從「讀取」走到「寫入與表單」。重點是 React 19 的 Server Actions:讓我們能在表單送出時直接在伺服器執行函式,不用寫 fetch、不用寫 API 端點。我們會做「新增預約」「刪除預約」「取消預約」三個寫入操作,並用 zod 做前端表單驗證、用 useFormStatus 顯示送出中的狀態。預約管理系統的對外頁會在這一篇定型。
延伸資源
- Next.js 15 App Router 官方文件(2025-07):
https://nextjs.org/docs/app,Server Components、Layout、約定檔名的完整說明。 - React 19 官方文件(2025):
react.dev,Server Components、useActionState、useFormStatus 等新 hooks。 - FastAPI Query 參數與 response_model(0.116,2025):
https://fastapi.tiangolo.com/tutorial/query-params/,分頁與篩選的標準寫法。 - Pydantic v2 BaseModel(2.11,2025):
https://docs.pydantic.dev/latest/,Field、Optional、response_model 的現行用法。 - Next.js 15 fetch 快取策略(2025):
https://nextjs.org/docs/app/api-reference/functions/fetch,cache、next.revalidate、tags 的差別。
留言
張貼留言