Backend for Frontend (BFF) is one of those patterns that's genuinely useful and also frequently misunderstood. The core idea is simple: instead of having the frontend call many services directly, a thin server layer aggregates, transforms, and shapes data for the specific needs of that frontend.
Where it goes wrong is in the implementation: teams reach for GraphQL on the client because "we need flexible queries," when the real need is a well-typed aggregation layer — and that's not the same thing.
What the BFF is actually solving
The problems that justify a BFF:
-
N+1 data fetching. The UI needs data from 3 services. Without a BFF, that's 3 sequential client-side fetches. With a BFF, it's 1 request.
-
Auth enforcement. Some auth logic belongs on the server, not the client. Token validation, admin checks, and API key management should never live in browser code.
-
Response shaping. Services return what they store. The UI needs what it renders. These shapes are often different, and the transformation logic doesn't belong in a React component.
-
Upstream API changes. When a backend service changes its response shape, the BFF absorbs the change. No frontend deploy required.
GraphQL solves some of these (flexible querying, batching). But it also introduces complexity — a schema, resolvers, a client-side query language — that isn't always warranted.
Why Next.js API routes are often enough
For many applications, especially internal tools and focused SaaS products, Next.js API routes are a better BFF implementation than GraphQL:
They're typed by default. You define the request/response types in TypeScript. The calling code (RTK Query, fetch, useSWR) consumes those types directly. No code generation step.
The aggregation is explicit. A route handler that calls 3 upstream services and merges the results is easy to read, easy to test, and easy to debug. GraphQL resolver chains are less obvious.
Deployment is trivial. API routes deploy with the Next.js app. No separate service, no separate infrastructure decision.
Auth is one middleware call. A withAuth wrapper on the route handler is all you need. No JWT directive configuration in a GraphQL schema.
When GraphQL on the client makes sense
GraphQL earns its complexity when:
- Multiple clients have genuinely different data needs. A web app, a mobile app, and a dashboard all querying the same GraphQL API and getting exactly the data they need is the pattern it was designed for.
- The data graph is complex. Interconnected entities where the client needs to traverse relationships arbitrarily benefit from a graph query model.
- You have a public API. External developers benefit from the self-documenting nature of a GraphQL schema.
In both DocMind and Kasu, we had one web client and a well-defined set of data needs. The BFF pattern with Next.js API routes was the right call.
The pattern in practice
For Kasu, the approval request detail page needed data from 4 upstream services: the approval engine, the user directory, the audit log, and the workflow config. Here's the BFF handler (simplified):
// app/api/approvals/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(req);
if (!session) return new Response(null, { status: 401 });
const [approval, user, auditLog, workflowConfig] = await Promise.all([
approvalService.get(params.id),
userService.get(approval.requesterId),
auditService.getLast10(params.id),
workflowService.getConfig(approval.workflowId),
]);
return Response.json({
approval,
requester: user,
recentActivity: auditLog,
workflow: workflowConfig,
} satisfies ApprovalDetailResponse);
}The client makes one call. The BFF makes 4 calls in parallel. The response is typed. Auth is checked before any upstream call.
The tradeoff to acknowledge
The BFF is another layer. It adds latency (server-side HTTP calls instead of in-process function calls). It needs to be maintained. Errors propagate through it.
For a simple CRUD app where the frontend maps directly to one backend service, a BFF is overhead. Add it when you have aggregation, auth, or transformation needs that genuinely live on the server side. Don't add it because it's architecturally fashionable.
The test: would you be embarrassed explaining this to a senior engineer in an interview? If the BFF is doing something that clearly belongs on the server, you're fine. If it's a pass-through for a single upstream call, you've added a layer for no reason.