跳到主要內容

Web Day 25 用 Next.js 串接 FastAPI(一)讀取

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 的差別。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...