# BFF 的兩種組合：htmx 回 partial，SvelteKit 才走 JSON

> BFF 後面有兩種接法：狀態在 server 的後台表格用 htmx 回 partial HTML，狀態在 client 的高互動頁才讓 SvelteKit 走 JSON。

原文：https://sheng.page/posts/bff-call-chains-with-htmx-and-sveltekit/ · 發布：2025-10-25 · 作者：Sheng

BFF 講久了會被當成一個架構名詞，其實它就是一條 call chain 上的一站。瀏覽器打進來，BFF 負責聚合多個下游、把回應裁剪成前端要的結構、把金鑰跟下游位址藏在背後，然後才往內部服務發請求。外部請求永遠不直接碰資料層，這是這層存在的理由，不是為了多一層而多一層。

到了 2025 年底，Fastify v5、SvelteKit 2、htmx 2 都已經是成熟的東西，可以放心拿來搭。比較少人講清楚的是：同一個 BFF 後面，前端那一端其實有兩種接法，而且不該二選一，要看互動本身決定。

## 一條 call chain 長什麼樣

先把骨架擺出來。瀏覽器 → BFF → 下游服務，這條鏈上 BFF 做三件事：聚合、裁剪、藏。聚合是把好幾個下游的回應併成一份；裁剪是只回前端真正用得到的欄位，把內部結構擋掉；藏是讓金鑰、下游 URL、認證細節都留在 BFF 後面，前端完全看不到。

往下游的內部呼叫會用 HMAC 簽一個 header，例如 `X-Internal-Auth`，驗簽通過才回應。這裡不需要外部 PKI，內部服務共用一把 secret 就夠，下游因此能拒絕未經 BFF 的請求；即使有人摸到內網，也沒辦法直接跟資料層對話。

```ts
// BFF 往下游：簽一個 HMAC header 再打
const sig = createHmac('sha256', INTERNAL_SECRET)
  .update(`${ts}.${body}`)
  .digest('hex')
await fetch(`${DOWNSTREAM}/orders`, {
  method: 'POST',
  headers: { 'X-Internal-Auth': `${ts}.${sig}` },
  body,
})
```

這條鏈到 BFF 為止都一樣，差別在 BFF 回給瀏覽器的那一段：要嘛回 HTML，要嘛回 JSON。

## 組合 A：BFF + htmx，直接回 partial HTML

後台管理那種畫面，互動模式其實很固定：點分頁、改篩選、按一下展開某列。這些動作的共通點是，狀態天然就在 server，前端不需要自己維護一份。這時候讓 BFF 直接回 partial HTML 最省力，省掉一整層 JSON 契約。

htmx 2 的做法很直白，按鈕掛 `hx-get` 指到 BFF 的 route，BFF 聚合完下游、把資料塞進模板、回一段 HTML 片段，htmx 換掉目標節點就結束。沒有前端 model、沒有序列化再反序列化、沒有「後端改欄位前端忘了改型別」這種事，因為根本沒有共享的型別契約要維護。

```html
<!-- 後台表格分頁，點了就換 tbody -->
<tbody id="rows"
  hx-get="/bff/orders?page=2&status=paid"
  hx-trigger="click from:#next"
  hx-target="#rows"
  hx-swap="outerHTML">
  ...
</tbody>
```

BFF 那端對應的 route，做的事情跟前面那條 call chain 一模一樣，只是最後吐的是 HTML 不是 JSON。聚合、裁剪、HMAC 簽章往下游，全都在這裡發生。前端拿到的是已經渲染好的結果，瀏覽器不用算。

這套做法少一層邊界，但互動複雜起來就不夠用：樂觀更新、拖拉排序與離線暫存都需要 client 端狀態，partial HTML 很難處理。選接法時應先看互動是否需要 client 端狀態，後台表格通常不需要，用 htmx 就夠了。

## 組合 B：BFF + SvelteKit，需要 client 互動才走 JSON

換個場景。交易紀錄查詢頁，使用者要切時間區間、即時看圖表、排序欄位還要記住捲動位置，這種互動的狀態活在 client，回 partial HTML 反而綁手綁腳。這時候走 SvelteKit 2 的 server load 跟 form actions 比較對。

差別在哪？SvelteKit 的 server `load` 本身就是一個跑在 server 的聚合點，它可以扮演 BFF 的角色：在 `load` 裡面打多個下游、簽 HMAC、把結果裁剪成頁面要的欄位，回傳的是結構化資料給元件用。首次進頁是 SSR，把第一份資料直接渲染進 HTML；之後的互動才透過 `+server.ts` 的 JSON endpoint 補資料，client 拿 JSON 自己更新狀態。

```ts
// +page.server.ts：load 就是 BFF 聚合點
export const load = async ({ fetch, url }) => {
  const range = url.searchParams.get('range') ?? '7d'
  const [trades, summary] = await Promise.all([
    signedFetch(fetch, `/internal/trades?range=${range}`),
    signedFetch(fetch, `/internal/summary?range=${range}`),
  ])
  return { trades: await trades.json(), summary: await summary.json() }
}
```

這裡 JSON 契約是有代價的：你多維護一份前後端共享的欄位定義，後端改欄位、前端得跟著調。但換來的是 client 端能自由地維護狀態、做即時互動，這在高互動頁面是值得的交換。重點是別把這個成本套到不需要的地方，後台表格用 SvelteKit 走 JSON 不是錯，只是平白多扛一層契約。

兩種組合共用同一條 call chain 與同一套 HMAC 下游保護，只在前端這段分岔。狀態活在 server 就讓 htmx 回 partial，活在 client 則讓 SvelteKit 走 JSON；先判斷狀態應該放哪裡，再決定接法，會比先選框架再勉強拼互動穩當。


## 補充筆記

- BFF 在 call chain 上做三件事：聚合下游、裁剪回應、藏住金鑰與下游位址，外部請求不直接碰資料層。
- 內部呼叫用 HMAC 簽 header（如 `X-Internal-Auth`），下游驗簽才回，不需要外部 PKI。
- 後台表格這種狀態在 server 的互動，用 htmx 回 partial HTML，省掉一層 JSON 契約。
- 高互動頁面狀態在 client，用 SvelteKit server load 當聚合點、需要時走 JSON endpoint。
- 判斷準則只有一句：這個互動的狀態該活在 server 還是 client，先問再選接法。
> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 列入 20260613 blog 翻新計劃，新漆未乾。