Building a BFF Layer over a Headless CMS API
This guide belongs to GraphQL vs REST API Tradeoffs and describes the option that makes the choice less final: a backend for frontend, or BFF, that sits between your frontends and the CMS. The BFF speaks to the CMS in whichever style suits each source and exposes endpoints shaped for your pages, with tokens hidden, responses validated and caching under your control.
A BFF is a small server owned by the frontend team. It is not a general-purpose API for the whole organization, and that is its strength: it can change as fast as the frontend, return exactly what each page needs, and encode decisions that do not belong in components, such as fallbacks, composition of content with commerce or search data, and cache lifetimes. In frameworks with server components or loaders, a thin BFF often already exists inside the app; a separate service becomes worthwhile when several frontends, such as web and mobile apps, share the same needs.
The Problem
A retailer had a web app and two mobile apps reading the same CMS. Each client queried the CMS directly with its own delivery token and its own queries. When the content model changed, three codebases needed updates on three release schedules, and the mobile apps, released every two weeks, regularly lagged behind and showed empty sections. Each client also combined content with prices from the commerce API in its own way, with slightly different rules for out-of-stock products, which customers noticed.
How a BFF Helps
A BFF solves these problems by moving shared logic to one place that deploys quickly.
Page-shaped endpoints. Instead of generic content queries, clients call endpoints such as /bff/pages/home or /bff/products/:slug that return exactly the data one screen needs. When the CMS model changes, the BFF adapts the mapping and the response shape stays stable, so mobile apps keep working until their next release.
Hidden credentials. CMS, commerce and search tokens live only in the BFF. Clients hold no secrets, and tokens can be rotated without app releases.
Validation and fallbacks. The BFF validates CMS responses against runtime schemas, drops invalid blocks and applies fallbacks once, for all clients.
Composition. Content and live data, such as prices and stock, are combined with one set of rules.
Caching. Responses carry cache headers and tags chosen per endpoint, so the CDN in front of the BFF serves most traffic.
Implementation
The example is a small Hono application, which runs on Node.js, edge runtimes and serverless platforms. The same structure works in Express, Fastify or framework route handlers.
// bff/src/app.ts
import { Hono } from "hono";
import { z } from "zod";
const app = new Hono();
const Editorial = z.object({
id: z.string(),
title: z.string(),
summary: z.string().default(""),
images: z.array(z.object({ url: z.string().url(), alt: z.string().default("") })).default([]),
});
const Commerce = z.object({ sku: z.string(), price: z.number(), currency: z.string(), stock: z.number().int() });
async function cmsProduct(slug: string) {
const query = `query($slug: String!) { productCollection(where: { slug: $slug }, limit: 1) { items { sys { id } title summary imagesCollection { items { url description } } } } }`;
const res = await fetch(process.env.CMS_GRAPHQL_URL!, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
body: JSON.stringify({ query, variables: { slug } }),
});
const item = (await res.json()).data?.productCollection?.items?.[0];
if (!item) return null;
return Editorial.parse({
id: item.sys.id,
title: item.title,
summary: item.summary ?? undefined,
images: item.imagesCollection.items.map((i: { url: string; description?: string }) => ({ url: i.url, alt: i.description })),
});
}
async function commerceProduct(slug: string) {
const res = await fetch(`${process.env.COMMERCE_URL}/products/${encodeURIComponent(slug)}`, {
headers: { Authorization: `Bearer ${process.env.COMMERCE_TOKEN}` },
});
return res.ok ? Commerce.parse(await res.json()) : null;
}
app.get("/bff/products/:slug", async (c) => {
const slug = c.req.param("slug");
const [editorial, commerce] = await Promise.all([cmsProduct(slug), commerceProduct(slug)]);
if (!editorial) return c.json({ error: "not_found" }, 404);
// One rule for every client: out-of-stock products show no price and a notice.
const offer = commerce && commerce.stock > 0
? { price: commerce.price, currency: commerce.currency, available: true }
: { available: false };
c.header("Cache-Control", "public, s-maxage=60, stale-while-revalidate=600");
c.header("Surrogate-Key", `product:${editorial.id} sku:${commerce?.sku ?? "none"}`);
return c.json({ id: editorial.id, title: editorial.title, summary: editorial.summary, images: editorial.images, offer });
});
export default app;
The CMS fetch here uses POST because it runs server to server, behind the BFF’s own CDN cache, so the CMS response itself does not need to be CDN-cacheable. The BFF’s GET endpoint is what the CDN caches. Price changes are handled by a short s-maxage and by purging the sku: tag from the commerce system’s webhooks; content changes purge the product: tag from the CMS webhook.
Versioning the BFF
Because mobile apps cannot be updated instantly, the BFF must keep old response shapes working for as long as old app versions are in use. Add fields freely; never remove or change them in place. When a real change is needed, introduce a new endpoint version and remove the old one only when analytics show that no supported app version still calls it. The web app, which deploys together with the BFF, can move to new shapes immediately.
Configuration Reference
| Concern | Recommendation | Why |
|---|---|---|
| Endpoint shape | one per screen or page type | Clients get exactly what they render. |
| Credentials | only in the BFF | Clients hold no secrets; rotation needs no release. |
| Validation | runtime schemas per source | Invalid content never reaches clients. |
| Source requests | in parallel, with timeouts | Latency is the slowest source, bounded. |
| Caching | cache headers and tags per endpoint | The CDN serves most traffic. |
| Versioning | additive changes, versioned endpoints for breaks | Old app versions keep working. |
Gotchas & Edge Cases
- Becoming a monolith. A BFF that accumulates business logic for many teams turns into the backend it was meant to avoid. Keep it to composition, mapping and caching, and push domain rules back to the owning services.
- Double caching confusion. With caching in the BFF, the CDN and the framework, stale data becomes hard to trace. Pick one primary cache, usually the CDN, and keep others short or off.
- Preview. Preview requests must bypass the BFF’s CDN cache and use preview tokens. Give preview its own path or host, and mark responses
no-store. - Timeouts. One slow source makes every page slow. Set per-source timeouts and return partial data with a flag, such as offer unavailable, rather than failing the page.
Worked Example
The retailer introduced a BFF with eleven endpoints, one per screen type, used by the web app immediately and by the mobile apps from their next releases. Content model changes now required a BFF deploy only; mobile apps stopped showing empty sections after model changes. The out-of-stock rule was defined once, so the inconsistencies customers had reported disappeared. The CDN in front of the BFF served 91 percent of requests, and CMS API usage dropped by more than half because three clients no longer queried it separately.
When Not to Build a BFF
A separate BFF service adds a deployment, monitoring and an on-call responsibility. For a single web app built with a framework that renders on the server, the framework’s data layer, such as server components, loaders or route handlers, already provides most of the benefits: tokens stay on the server, responses can be validated and composed, and caching is available through the framework. A separate service pays off when several clients share the same needs, when mobile apps with slow release cycles depend on the content model, or when composition involves sources that should not be reachable from the frontend’s runtime. Start with the framework’s server layer and extract a BFF when a second client appears.
Rollout Checklist
- List each screen’s data needs across all clients.
- Create page-shaped endpoints with runtime validation per source.
- Move all source credentials into the BFF.
- Fetch sources in parallel with timeouts and explicit partial-data flags.
- Put a CDN in front with cache headers and tags per endpoint, and purge from source webhooks.
- Version endpoints additively and track which app versions call them.
Frequently Asked Questions
Should the BFF expose GraphQL or REST?
Either works, depending on the clients. REST endpoints per screen are simple and cache well; a GraphQL BFF suits teams that want clients to select fields. The important part is that the BFF owns the shape, not the CMS.
Does a BFF add latency?
One extra hop, usually a few milliseconds when it runs close to the CDN, and it often removes more than it adds by composing several sources in parallel and serving from cache.
Who should own the BFF?
The frontend teams that consume it. A BFF owned by a separate backend team loses the fast iteration that justifies it.
Can the BFF write to the CMS?
It can proxy editorial actions, but keep delivery and management paths separate, with different credentials and controls, as described in RBAC and audit trails.