Two Ways to Wire a BFF: htmx Returns Partials, SvelteKit Goes JSON
Two Ways to Wire a BFF: htmx Returns Partials, SvelteKit Goes JSON
Talk about a BFF for long enough and it starts sounding like an architecture noun. It isn’t. It’s one stop on a call chain. The browser comes in, the BFF aggregates a few downstream services, trims the response down to the shape the front end actually wants, hides the secrets and downstream addresses behind itself, and only then talks to internal services. External requests never touch the data layer directly. That’s the reason this layer exists — not to add a tier for the sake of having one.
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.
What the Call Chain Looks Like
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; a shared secret across internal services is enough. The point is letting downstream reject anything that didn’t come from the BFF. Even if someone gets onto the internal network, they can’t talk to the data layer directly.
// 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. The fork is the last leg — what the BFF hands back to the browser: HTML, or JSON.
Combination A: BFF + htmx, Return Partial HTML Directly
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, htmx swaps the target node, done. No front-end model, no serialise-then-deserialise round trip, none of that “backend changed a field, front end forgot to update the type” — because there’s no shared type contract to maintain in the first place.
<!-- 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 does exactly what the call chain above described — it just emits HTML instead of JSON at the end. Aggregation, trimming, HMAC signing downstream, all of it happens here. The front end receives a rendered result; the browser does no computation.
The upside is one fewer boundary. The downside is just as real: once the interaction gets complex — optimistic updates on the client, drag-to-reorder, offline buffering — partial HTML stops holding up. That’s client-state territory. So the rule isn’t “htmx is trendier”, it’s “does this interaction need client-side state”. An admin table doesn’t, so htmx fits.
Combination B: BFF + SvelteKit, Go JSON When the Client Needs to Interact
Different scene. A transaction-history page where the user switches date ranges, watches a chart update live, sorts columns, and expects the scroll position remembered — that state lives on the client, and returning partial HTML just ties your hands. Here SvelteKit 2’s server load and form actions are the better fit.
Where’s the difference? SvelteKit’s server load is itself a server-side aggregation point — it can play the BFF role: call several downstream services inside load, sign the HMAC, trim the result to the shape the page wants, and return structured data for the components. The first hit is SSR, rendering that first payload straight into HTML; subsequent interactions pull data through a JSON endpoint in +server.ts, and the client holds and updates its own 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 here has a price: you maintain a shared shape across front and back, and a backend field change drags the front end along. What you buy is a client free to hold state and run live interaction, which is a fair trade on a high-interaction page. The point is not paying that cost where you don’t need to. Running an admin table through SvelteKit and JSON isn’t wrong — you’re just carrying a contract for nothing.
Both combinations share the same call chain and the same HMAC downstream protection; only the front-end leg forks. The deciding rule fits in a sentence: should this interaction’s state live on the server or the client. Server, htmx returns a partial. Client, SvelteKit goes JSON. Ask that first, then pick the wiring — don’t reach for a framework and then bend the interaction to fit it.
Key Takeaways
- On the call chain, a BFF does three jobs: aggregate downstream, trim the response, hide secrets and downstream addresses — 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.
- The whole rule is one line: should this interaction’s state live on the server or the client — ask first, then pick the wiring.
Sheng’s take, drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.