Dieser Artikel ist auf Englisch veröffentlicht.
Turning Next.js Route Handlers Into a Deliberate BFF Layer
Learn what the Backend for Frontend pattern solves, why it's resurfacing in Next.js apps, and how to avoid turning route handlers into bloated god objects.
A developer on a mobile team once asked why the checkout screen required six separate API calls to render, with three of them returning overlapping fields pulled from the same underlying service. The honest explanation was that nobody owned the shape of what the frontend actually needed, so every team shipped its own version of the truth and left the client to stitch it together. That's not really a technical problem, it's an organizational one, and it happens to be exactly what the Backend for Frontend pattern was designed to address. That's likely why the pattern has resurfaced in several posts recently, after spending years as a mostly-forgotten idea from Netflix and SoundCloud engineering blogs a decade ago.
BFF is far from a new idea. Plenty of engineers were building it under a different label before "BFF" became the standard term. What's new is that it's being reintroduced in the specific context of Next.js applications that have gradually evolved into API aggregation layers, often without anyone deciding that on purpose. Given that trend, it's worth adopting the pattern intentionally rather than stumbling into it by accident.
The Shape of the Problem
The scenario keeps repeating itself in slightly different forms. You have a handful of backend services, some built in-house, others vendor APIs bolted on afterward. You also have multiple clients: a web app, a mobile app, maybe an internal admin tool. Each of these clients has different requirements. The mobile client wants a lightweight payload since it's often running on an unreliable network. The web dashboard wants a fully denormalized response with everything pre-joined so it avoids a chain of sequential requests. The admin tool wants raw, unprocessed data because the people using it are usually troubleshooting something.
Skip the BFF layer, and you're left with one of two outcomes, both bad. Either your core services balloon into bloated, general-purpose endpoints trying to satisfy every possible consumer at once, which is basically a graveyard of "just add another optional query parameter," or each client ends up reimplementing its own aggregation logic on top of the same backend services. That's how you end up with three slightly different, and slightly wrong, definitions of "active user" scattered across three separate codebases.
What a BFF Actually Is
A Backend for Frontend is a lightweight server layer, typically one per client type or client family, sitting between the UI and the real backend services. Its only job is to shape data for the specific consumer it serves. The web-facing BFF and the mobile-facing BFF might call identical underlying services yet return entirely different response shapes, because each is solving a distinct presentation problem.
This idea keeps coming up in Next.js conversations because the App Router essentially gives you a BFF layer for free, provided you use it that way. Route handlers and server components already execute on the server, already sit between the client and the backend, and already have the capability to fire off multiple parallel requests and reshape the combined result before it ever reaches the browser. Many teams that adopted the App Router are already performing BFF-style work without formally naming it or applying it consistently.
// app/api/checkout-summary/route.ts
// A web-specific BFF endpoint that aggregates three backend calls
// into the one shape the checkout screen actually needs
export async function GET(req: Request) {
const userId = getUserIdFromSession(req); const [cart, pricing, shippingOptions] = await Promise.all([
fetch(`${CART_SERVICE}/users/${userId}/cart`).then(r => r.json()),
fetch(`${PRICING_SERVICE}/quote?userId=${userId}`).then(r => r.json()),
fetch(`${SHIPPING_SERVICE}/options?userId=${userId}`).then(r => r.json()),
]); // Reshape into exactly what the checkout component renders,
// nothing more. This is the whole point.
return Response.json({
items: cart.items.map((i: CartItem) => ({ id: i.id, name: i.name, qty: i.qty })),
total: pricing.total,
currency: pricing.currency,
shipping: shippingOptions.filter((o: ShippingOption) => o.available),
});
}
Now picture the mobile app instead hitting those same three services directly and handling its own aggregation in Swift or Kotlin, independently reproducing the logic for what counts as an available shipping option. That's precisely how drift creeps in. Two teams, two languages, two separate interpretations of the same business rule, and sooner or later those interpretations disagree in a way nobody catches until a support ticket lands on someone's desk.
Where This Gets Political, Not Just Technical
The hard part of adopting BFF the right way has little to do with writing the code. It's about who owns it. A BFF built for the web team only works if someone with the authority to change it quickly is in charge, ideally the web team itself, not a shared platform group locked into a two-week release schedule. If your BFF layer ends up owned by a separate backend team that treats it as just another microservice subject to the same approval process as everything else, you haven't solved the original bottleneck, you've simply relocated it a step closer to the client.
This is also why the pattern fits so naturally into a Next.js codebase: the developers writing the route handlers are typically the same developers writing the components that call them. There's no ticket to file, no handoff between teams, no separate deployment pipeline to coordinate. If you need to change the shape of the checkout summary response, you make that change in the same pull request as the component that displays it.
The Trap: Turning Every Route Handler Into a God Object
There's a failure mode on the other end of the spectrum that rarely gets mentioned. Once a team discovers that route handlers can aggregate whatever they want, the urge to build one giant endpoint that returns the app's entire initial state in a single call becomes hard to resist, since fewer round trips looks like an obvious win. Sometimes it is. But it also means a single route handler ends up supporting six unrelated features at once, and a tweak to the shipping module now forces you into the same file as the loyalty points widget, because both got stuffed into something like getInitialAppState.
The pattern holds up when a BFF endpoint corresponds to one screen or one clearly scoped feature, not to "everything the app could conceivably need on load." If you struggle to name the endpoint after a specific piece of UI, that's usually a sign it has grown too broad.
There's a related warning sign worth watching for too: BFF endpoints that drift from reshaping data into reimplementing business logic. A route handler that decides on its own whether a customer qualifies for free shipping, instead of simply asking the pricing service and forwarding the answer, has quietly turned into a second source of truth for a rule that already exists elsewhere in the system. The job of a BFF is to translate and combine data, not to make decisions. As soon as it starts deciding things, you're back to the same drift problem discussed earlier, just tucked one layer nearer to the client, where it's much harder to catch during a code review.
Where I've Landed on It
BFF doesn't have to be treated as some formal architectural commitment complete with a diagram and a wiki page, though there's nothing wrong with doing that if it fits how your organization works. For most teams already building on Next.js, adopting it is less about introducing something new and more about naming what's already there: acknowledging that your route handlers are functioning as a BFF, dropping the pretense that they're generic API routes, and structuring them around what the client actually needs rather than around backend service boundaries. The pattern itself was never the difficult part. What's difficult, and what most teams put off, is admitting that aggregation logic needs a real home, and then handing that home to the frontend team that actually owns it, rather than waiting until the checkout screen needs six separate calls to render before anyone stops to ask why.