Two Ways to Wire a BFF: htmx Returns Partials, SvelteKit Goes JSON
Two Ways to Wire a BFF: htmx Returns Partials, SvelteKit Goes JSON
A BFF is one stop on a call chain. It receives the browser request, aggregates downstream services, trims the response to the shape the front end needs, and keeps secrets and downstream addresses behind the server boundary. External requests therefore never touch the data layer directly.
By late 2025, Fastify v5, SvelteKit 2, and htmx 2 are all mature enough to build on without flinching. What gets less airtime is this: behind the same BFF, the front-end side has two ways to connect, and it shouldn’t be an either/or choice. The interaction decides.
The call chain
Lay out the skeleton first. Browser → BFF → downstream services. Along that chain the BFF does three things: aggregate, trim, hide. Aggregate means folding several downstream responses into one. Trim means returning only the fields the front end genuinely uses and keeping internal structure out of sight. Hide means secrets, downstream URLs, and auth details all stay behind the BFF, invisible to the client.
The downstream hop isn’t a bare call. Internal requests carry an HMAC signature in a header, say X-Internal-Auth, and downstream only answers if the signature checks out. No external PKI is needed; a shared secret across internal services is enough. Downstream can then reject anything that did not come through the BFF, including traffic from elsewhere on the internal network.
// BFF to downstream: sign an HMAC header, then call
const sig = createHmac('sha256', INTERNAL_SECRET)
.update(`${ts}.${body}`)
.digest('hex')
await fetch(`${DOWNSTREAM}/orders`, {
method: 'POST',
headers: { 'X-Internal-Auth': `${ts}.${sig}` },
body,
})
Everything up to the BFF is identical. Only the last leg differs: the BFF hands HTML or JSON back to the browser.
Combination A: BFF + htmx, returning partial HTML
Admin screens have a fairly fixed interaction model: click a page, change a filter, expand a row. What these share is that the state naturally lives on the server; the front end doesn’t need its own copy. Here, having the BFF return partial HTML directly is the smoothest path, and it drops a whole JSON contract.
htmx 2 keeps it blunt. A button gets an hx-get pointed at a BFF route; the BFF aggregates downstream, drops the data into a template, returns an HTML fragment, and htmx swaps the target node. There is no front-end model or serialise-then-deserialise round trip, and no shared type contract for a backend field change to break.
<!-- Admin table paging: click swaps the tbody -->
<tbody id="rows"
hx-get="/bff/orders?page=2&status=paid"
hx-trigger="click from:#next"
hx-target="#rows"
hx-swap="outerHTML">
...
</tbody>
The matching BFF route follows the same call chain and emits HTML instead of JSON at the end. Aggregation, trimming, and downstream HMAC signing all happen there. The front end receives a rendered result, so the browser does no computation.
This removes one boundary, but partial HTML stops fitting once the interaction needs optimistic client updates, drag-to-reorder, or offline buffering. Those features belong to client-side state management. An admin table usually has no such requirement, so htmx remains a good fit.
Combination B: BFF + SvelteKit, using JSON for client interaction
A transaction-history page has a different state model. Switching date ranges, updating a chart live, sorting columns, and remembering the scroll position all require client state, so returning partial HTML becomes restrictive. SvelteKit 2’s server load and form actions fit that case better.
SvelteKit’s server load is itself a server-side aggregation point and can play the BFF role. It calls several downstream services inside load, signs the HMAC, trims the result to the shape the page wants, and returns structured data for the components. The first hit uses SSR to render that payload into HTML; subsequent interactions pull data through a JSON endpoint in +server.ts, and the client holds and updates its state from that JSON.
// +page.server.ts: load is the BFF aggregation point
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() }
}
The JSON contract has a price: you maintain a shared shape across front and back, and a backend field change drags the front end along. In return, the client can hold state and run live interaction, which is a fair trade on a high-interaction page. An admin table rarely needs that freedom, so routing it through SvelteKit and JSON adds a contract with little benefit.
Both combinations share the same call chain and HMAC downstream protection; only the front-end leg differs. State that lives on the server can return through an htmx partial, while client state can use SvelteKit and JSON. Decide where the state belongs before choosing the wiring, so the interaction does not have to bend around the framework.
Notes
- On the call chain, a BFF aggregates downstream, trims the response, and hides secrets and downstream addresses, so external requests never touch the data layer.
- Sign internal calls with an HMAC header (e.g.
X-Internal-Auth) and have downstream verify before answering; no external PKI needed. - For server-resident interactions like admin tables, let htmx return partial HTML and skip a JSON contract.
- For high-interaction pages where state lives on the client, use SvelteKit’s server load as the aggregation point and a JSON endpoint when needed.
- Decide whether the interaction’s state belongs on the server or the client before choosing the wiring.
Sheng’s take, drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.