Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 063 / 100

Backend for frontend (BFF): patterns, pitfalls and fixes

A dedicated backend per client type.

Expona Admin8 min read

Why we built a dedicated backend

Your web application and mobile app are fundamentally different clients with different constraints. The web browser can handle a 500KB JSON response; a mobile device on a 3G connection cannot. Web users expect instant interactivity; mobile users are bandwidth-conscious. A backend for frontend (BFF): patterns, pitfalls and fixes is a dedicated backend layer that acknowledges this reality and optimizes for each client independently.

We've seen teams attempt to serve both web and mobile from a single monolithic API. The result is always the same: compromise. The endpoint either returns too much data for mobile or too little for the web. Business logic leaks into client code. Caching strategies fail because clients have incompatible needs. A BFF pattern solves this by placing an intermediate layer between each client type and your core backend services, tailored to that client's specific requirements.

Web and mobile need different data shapes

Consider an e-commerce product listing. On web, users browse with rich filtering, sorting, and immediate visual feedback. On mobile, users are scrolling through limited screen real estate with intermittent connectivity. The data they need is fundamentally different.

A shared API might return:

typescript
{
  "products": [
    {
      "id": "prod_123",
      "name": "Blue Cotton T-Shirt",
      "description": "Premium organic cotton...",
      "price": 29.99,
      "originalPrice": 39.99,
      "images": [
        { "url": "...", "alt": "Front view", "width": 1200, "height": 1200 },
        { "url": "...", "alt": "Back view", "width": 1200, "height": 1200 },
        { "url": "...", "alt": "Detail", "width": 1200, "height": 1200 }
      ],
      "reviews": [
        { "rating": 5, "text": "Great quality", "author": "Alice", "date": "2024-01-15" },
        { "rating": 4, "text": "Good fit", "author": "Bob", "date": "2024-01-10" }
      ],
      "inventory": [
        { "size": "XS", "color": "Blue", "quantity": 12 },
        { "size": "S", "color": "Blue", "quantity": 8 }
      ],
      "relatedProducts": ["prod_456", "prod_789", "prod_101"]
    }
  ]
}

This response works for web but wastes bandwidth on mobile. Mobile users don't need full review text or all inventory variants at scroll time. Web users need rich filtering context that mobile doesn't.

With a BFF pattern, your mobile backend returns only essentials:

typescript
{
  "products": [
    {
      "id": "prod_123",
      "name": "Blue Cotton T-Shirt",
      "price": 29.99,
      "image": "...",
      "rating": 4.5,
      "reviewCount": 127
    }
  ]
}

And your web backend returns the rich, detailed response. Each client gets exactly what it needs, nothing more. This isn't just about payload size. It's about data shape, response structure, and the business logic required to assemble that data.

BFF aggregates and tailors responses

A BFF sits between your clients and your backend services. It orchestrates calls to multiple services, transforms responses, handles caching, and applies client-specific business logic. It is not a pass-through proxy.

Imagine your core backend has three services: product service, inventory service, and review service. Your mobile BFF might aggregate them like this:

typescript
// Mobile BFF endpoint
async function getProductsForMobile(page: number, limit: number) {
  const products = await productService.list({ page, limit });
  
  // For mobile, fetch inventory in parallel only for visible products
  const inventoryMap = await inventoryService.checkStock(
    products.map(p => p.id),
    ['S', 'M', 'L'] // Only common sizes
  );
  
  // Skip reviews entirely on mobile list—users tap to see details
  
  return products.map(product => ({
    id: product.id,
    name: product.name,
    price: product.price,
    image: product.primaryImage,
    inStock: inventoryMap[product.id]?.inStock ?? false,
    rating: product.averageRating
  }));
}

Your web BFF, meanwhile, does more work:

typescript
// Web BFF endpoint
async function getProductsForWeb(
  page: number,
  limit: number,
  filters: FilterOptions
) {
  const products = await productService.list({ 
    page, 
    limit,
    filters // Web supports complex filtering
  });
  
  // Fetch everything in parallel
  const [inventory, reviews, related] = await Promise.all([
    inventoryService.getAllInventory(products.map(p => p.id)),
    reviewService.getReviews(products.map(p => p.id), { limit: 5 }),
    relatedProductsService.getRelated(products.map(p => p.id))
  ]);
  
  return products.map(product => ({
    ...product,
    inventory: inventory[product.id],
    reviews: reviews[product.id],
    relatedProducts: related[product.id],
    filterContext: buildFilterContext(filters, product)
  }));
}

The BFF pattern lets each client team optimize independently. Mobile can prioritize latency and bandwidth. Web can prioritize feature richness. Neither blocks the other.

Owned by the frontend team

This is the organizational insight at the heart of the BFF pattern: the backend layer should be owned and maintained by the frontend team, not by a separate backend team. This is a crucial distinction.

When a mobile frontend engineer wants to change the data shape—to add a field, remove one, or restructure a response—they shouldn't have to file a ticket with a backend team, negotiate, wait for a deployment, and coordinate releases. They should own their BFF and make that change immediately.

In practice, this means:

  • The mobile BFF lives in the mobile team's repository or a dedicated mobile-bff repository they control
  • The web BFF lives in the web team's repository or a dedicated web-bff repository they control
  • Each team deploys independently on their own schedule
  • Backend services expose stable APIs that both BFFs consume; they don't change for every BFF whim, but they provide the primitive operations both clients need

This separation of concerns resolves a common coordination problem. Without it, backend becomes a bottleneck. Every UI change requires API changes, which requires backend availability, which blocks shipping. With ownership distributed to frontend teams, each client can move at its own pace while still consuming shared backend services reliably.

Next.js route handlers can act as a BFF

If you're building a Next.js application and a separate mobile app, your Next.js route handlers can serve as your web BFF. This is a practical, lightweight approach that avoids a separate infrastructure.

Instead of having your React components call backend services directly, they call route handlers, which aggregate and tailor responses:

typescript
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const page = parseInt(searchParams.get('page') ?? '1');
  const limit = parseInt(searchParams.get('limit') ?? '20');
  const filters = parseFilters(searchParams);

  try {
    const [products, inventory, reviews] = await Promise.all([
      fetch(`${process.env.BACKEND_URL}/products`, {
        method: 'POST',
        body: JSON.stringify({ page, limit, filters })
      }).then(r => r.json()),
      
      fetch(`${process.env.BACKEND_URL}/inventory/check`, {
        method: 'POST',
        body: JSON.stringify({ productIds: products.map(p => p.id) })
      }).then(r => r.json()),
      
      fetch(`${process.env.BACKEND_URL}/reviews`, {
        method: 'POST',
        body: JSON.stringify({ productIds: products.map(p => p.id), limit: 5 })
      }).then(r => r.json())
    ]);

    const enrichedProducts = products.map(product => ({
      ...product,
      inventory: inventory[product.id],
      reviews: reviews[product.id]
    }));

    return NextResponse.json(enrichedProducts);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch products' },
      { status: 500 }
    );
  }
}

Your React components now call your own BFF route, not external services:

typescript
// app/products/page.tsx
const response = await fetch('/api/products?page=1&limit=20');
const products = await response.json();

This approach gives you several advantages: you control the data shape, you can cache at the BFF layer, you handle errors consistently, and you avoid CORS issues. Your frontend team owns this layer and can change it without coordinating with backend.

For a separate mobile app (iOS, Android), you'd have a different BFF—either another Next.js route handler serving a different domain, or a dedicated service (Node.js, Go, whatever you prefer) that the mobile team owns.

Common mistakes

Making the BFF too thin. Developers sometimes treat a BFF as just a request forwarder, adding little value. The BFF should orchestrate, transform, and optimize. If it's just passing requests through, you haven't solved the problem.

Duplicating too much logic. Every BFF will have some unique business logic, but avoid duplicating core logic across multiple BFFs. Use shared libraries or service calls to a single source of truth. If three BFFs have different implementations of the same calculation, you're creating a maintenance nightmare.

Letting BFFs call other BFFs. Tier your architecture clearly. Clients call BFFs. BFFs call backend services. Never have a BFF call another BFF. This creates circular dependencies and makes it impossible to reason about your system.

Abandoning ownership boundaries. The whole point of BFF ownership is frontend autonomy. If backend engineers still approve every BFF change, you've missed the pattern. Trust the frontend team to own their layer and only escalate when they need something from backend services.

Over-caching at the BFF layer. Caching is useful, but be careful. If your BFF caches and returns stale data to a user, that user sees inconsistency. Cache aggressively for list endpoints but conservatively for detail endpoints. Let the client specify cache-control headers when needed.

Key takeaways

  • A BFF is a dedicated backend layer per client type that aggregates, transforms, and tailors responses to that client's specific needs and constraints
  • Web and mobile clients have fundamentally different bandwidth, latency, and feature requirements; serving both from a shared API forces compromise
  • A BFF should orchestrate multiple backend services, not just proxy requests; it handles data transformation, caching, and client-specific business logic
  • Ownership matters: the BFF should be owned by the frontend team for that client, enabling independent iteration without backend team coordination
  • Next.js route handlers are a practical way to implement a web BFF; mobile apps would have a separate BFF owned by the mobile team
  • Keep the architecture simple: clients call BFFs, BFFs call backend services, nothing else; avoid BFFs calling other BFFs
  • The BFF pattern solves a coordination problem in large teams by pushing decision-making about data shape to the frontend teams that use that data
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles