Published on

The Backend for Frontend Pattern — Why Your Mobile and Web Apps Need Different APIs

Authors

Introduction

Your mobile app needs small payloads and few requests. Your web app needs rich data structures. Your admin dashboard needs different endpoints entirely. A single backend API trying to serve all clients with one shape is a compromise that satisfies no one. A BFF (Backend for Frontend) is a dedicated API layer per client type, composed from your core services.

BFF Concept and Motivation

A BFF sits between each client (web, mobile, admin) and your backend services. It aggregates data, optimizes payloads, and handles client-specific concerns without burdening the core API.

// Architecture overview:
//
//  Mobile App          Web App          Admin Dashboard
//      |                 |                      |
//      |                 |                      |
//   Mobile BFF        Web BFF             Admin BFF
//      |                 |                      |
//      +--------+--------+--------+--------+-----+
//               |                 |
//         Orders Service      Users Service
//         Payments Service    Analytics Service
//         Shipping Service    ...

// Each BFF is a thin orchestration layer, not duplicate business logic
// Business logic stays in core services
// BFFs are slim, stupid, and focused on client optimization

Aggregation of Multiple Services

A BFF fetches from multiple services and combines responses into one call:

// src/mobile-bff/routes/order-details.ts
import express from 'express'
import { ordersService, paymentsService, shippingService } from '../services'

export const router = express.Router()

router.get('/orders/:orderId', async (req, res) => {
  try {
    // Mobile client wants: order + payment status + shipping tracking in ONE call
    const [order, payment, shipping] = await Promise.all([
      ordersService.getOrder(req.params.orderId),
      paymentsService.getPaymentStatus(req.params.orderId),
      shippingService.getTracking(req.params.orderId)
    ])

    // Aggregate into minimal response
    res.json({
      id: order.id,
      status: order.status,
      total: order.total,
      items: order.items.map(item => ({
        id: item.id,
        name: item.name,
        price: item.price,
        qty: item.quantity
      })),
      payment: {
        status: payment.status,
        method: payment.method
      },
      shipping: shipping ? {
        status: shipping.status,
        eta: shipping.estimatedDelivery,
        tracking: shipping.trackingNumber
      } : null
    })
  } catch (error) {
    res.status(500).json({ error: 'Failed to load order' })
  }
})

Per-Client Payload Optimization

Different clients have different constraints:

// src/mobile-bff/services/product-service.ts
// Mobile clients: minimal data, small payloads

export async function getProductSummary(productId: string) {
  const product = await coreAPI.get(`/products/${productId}`)

  return {
    id: product.id,
    name: product.name,
    price: product.price,
    image: product.thumbnailUrl, // Small image only
    rating: product.averageRating
  }
}

// src/web-bff/services/product-service.ts
// Web clients: rich data for detailed pages

export async function getProductDetails(productId: string) {
  const product = await coreAPI.get(`/products/${productId}`)
  const reviews = await coreAPI.get(`/products/${productId}/reviews?limit=50`)
  const related = await coreAPI.get(`/products/${productId}/related`)
  const inventory = await coreAPI.get(`/inventory/${productId}`)

  return {
    id: product.id,
    name: product.name,
    description: product.longDescription,
    price: product.price,
    originalPrice: product.msrp,
    rating: product.averageRating,
    reviewCount: product.reviewCount,
    images: product.allImages, // All sizes
    specs: product.specifications,
    reviews: reviews.data.map(r => ({
      author: r.author,
      rating: r.rating,
      text: r.text,
      helpful: r.helpfulCount
    })),
    relatedProducts: related.data.map(p => ({
      id: p.id,
      name: p.name,
      price: p.price,
      image: p.thumbnailUrl
    })),
    inStock: inventory.quantity > 0,
    availableQuantity: inventory.quantity
  }
}

// src/admin-bff/services/product-service.ts
// Admin clients: everything for operational decisions

export async function getProductForAdmin(productId: string) {
  const product = await coreAPI.get(`/products/${productId}`)
  const sales = await coreAPI.get(`/analytics/product-sales/${productId}`)
  const inventory = await coreAPI.get(`/inventory/${productId}`)
  const costs = await coreAPI.get(`/admin/product-costs/${productId}`)
  const suppliers = await coreAPI.get(`/suppliers?productId=${productId}`)

  return {
    // Public fields
    id: product.id,
    name: product.name,
    price: product.price,

    // Operational fields
    sku: product.sku,
    cost: costs.unitCost,
    margin: ((product.price - costs.unitCost) / product.price) * 100,

    // Inventory
    currentStock: inventory.quantity,
    lowStockThreshold: inventory.lowStockAlert,
    reorderPoint: inventory.reorderPoint,

    // Sales analytics
    monthlySales: sales.last30Days,
    quarterlyRevenue: sales.lastQuarter,
    trending: sales.trendingUp,

    // Supply chain
    primarySupplier: suppliers.data[0],
    supplierOptions: suppliers.data.map(s => ({
      id: s.id,
      name: s.name,
      leadTime: s.leadTimeDays
    }))
  }
}

Authentication Delegation

Each BFF handles auth for its clients, then calls backend services with a service account:

// src/mobile-bff/middleware/auth.ts
import jwt from 'jsonwebtoken'

export async function authenticateUser(req: Request, res: Response, next: NextFunction) {
  try {
    const token = req.headers.authorization?.split(' ')[1]
    if (!token) throw new Error('No token')

    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
      userId: string
      email: string
      deviceId: string
    }

    // Mobile can verify additional constraints (device binding, etc)
    if (req.body.deviceId && req.body.deviceId !== decoded.deviceId) {
      throw new Error('Device mismatch')
    }

    req.user = decoded
    next()
  } catch (error) {
    res.status(401).json({ error: 'Unauthorized' })
  }
}

// src/mobile-bff/services/core-api-client.ts
// BFF calls backend services with its own service account

export class CoreAPIClient {
  private serviceToken: string

  constructor() {
    // BFF has its own token with "service account" permissions
    this.serviceToken = this.issueServiceToken()
  }

  private issueServiceToken(): string {
    return jwt.sign(
      {
        service: 'mobile-bff',
        scopes: ['orders.read', 'users.read', 'payments.read']
      },
      process.env.SERVICE_SECRET!
    )
  }

  async get<T>(path: string, userId?: string): Promise<T> {
    const headers: Record<string, string> = {
      Authorization: `Bearer ${this.serviceToken}`,
      'Content-Type': 'application/json'
    }

    // Pass user context for authorization
    if (userId) {
      headers['X-User-ID'] = userId
    }

    const response = await fetch(`${process.env.CORE_API_URL}${path}`, { headers })
    if (!response.ok) throw new Error(`API error: ${response.status}`)
    return response.json()
  }
}

// Core API validates: service is mobile-bff, X-User-ID matches their data

BFF as Composition Layer (No Business Logic)

This is critical: BFFs compose and optimize, they don't duplicate business logic:

// CORRECT: BFF as composition layer
// src/web-bff/use-cases/checkout.ts

export class CheckoutUseCase {
  constructor(
    private ordersService: OrdersServiceClient,
    private paymentsService: PaymentsServiceClient,
    private shippingService: ShippingServiceClient
  ) {}

  // BFF orchestrates but doesn't duplicate pricing logic, tax calculation, etc
  // Those stay in core services
  async prepareCheckout(userId: string, cartItems: CartItem[]) {
    // Call core service to calculate final price (tax, discounts, etc)
    const pricing = await this.ordersService.calculatePrice({
      userId,
      items: cartItems
    })

    // Verify payment method
    const paymentMethods = await this.paymentsService.getUserPaymentMethods(userId)

    // Get shipping options
    const shippingOptions = await this.shippingService.getShippingOptions({
      items: cartItems,
      userId
    })

    // Return aggregated response for client
    return {
      pricing,
      paymentMethods,
      shippingOptions
    }
  }
}

// WRONG: Duplicating business logic in BFF
export class CheckoutUseCase {
  async prepareCheckout(userId: string, cartItems: CartItem[]) {
    // Don't recalculate tax in BFF!
    const subtotal = cartItems.reduce((sum, item) => sum + item.price * item.qty, 0)
    const tax = subtotal * 0.08 // WRONG - tax rules are in core service
    const shipping = subtotal > 100 ? 0 : 10 // WRONG - shipping logic elsewhere

    // Now if tax rules change, you have to update both services
  }
}

When BFF Becomes a Bottleneck

BFFs are efficient until they're not. Monitor for these patterns:

// Problem: BFF calling backend in series when it could be parallel
// src/bff/slow-endpoints.ts

// SLOW: Sequential calls
export async function getOrderWithDetails(orderId: string) {
  const order = await ordersService.getOrder(orderId) // Wait 100ms
  const payment = await paymentsService.getPayment(order.paymentId) // Wait 100ms
  const tracking = await shippingService.getTracking(orderId) // Wait 100ms
  // Total: 300ms

  return { order, payment, tracking }
}

// FAST: Parallel calls
export async function getOrderWithDetails(orderId: string) {
  const order = await ordersService.getOrder(orderId)

  const [payment, tracking] = await Promise.all([
    paymentsService.getPayment(order.paymentId),
    shippingService.getTracking(orderId)
  ])
  // Total: 100ms

  return { order, payment, tracking }
}

// Solution: Cache frequently-called data
import NodeCache from 'node-cache'

const cache = new NodeCache({ stdTtl: 60 }) // 60 second TTL

export async function getProductCategory(categoryId: string) {
  const cached = cache.get<Category>(`category:${categoryId}`)
  if (cached) return cached

  const category = await productsService.getCategory(categoryId)
  cache.set(`category:${categoryId}`, category)
  return category
}

// Solution: Service-to-service caching via Redis
import redis from 'redis'

const redisClient = redis.createClient()

export async function getUserPreferences(userId: string) {
  const cached = await redisClient.get(`user:prefs:${userId}`)
  if (cached) return JSON.parse(cached)

  const prefs = await usersService.getPreferences(userId)
  await redisClient.setex(`user:prefs:${userId}`, 3600, JSON.stringify(prefs))
  return prefs
}

GraphQL as Universal BFF Alternative

Instead of per-client REST endpoints, use GraphQL as a single BFF that adapts to each client:

// src/graphql-bff/schema.ts
import { buildSchema } from 'graphql'

export const schema = buildSchema(`
  type Order {
    id: ID!
    status: OrderStatus!
    total: Float!
    items: [OrderItem!]!
    payment: Payment!
    shipping: ShippingInfo
  }

  type OrderItem {
    id: ID!
    name: String!
    price: Float!
    quantity: Int!
  }

  type Payment {
    status: PaymentStatus!
    method: String!
  }

  type ShippingInfo {
    status: ShippingStatus!
    eta: String
    tracking: String
  }

  type Query {
    order(id: ID!): Order
    orders(limit: Int): [Order!]!
  }
`)

// Mobile can request minimal fields
// query {
//   order(id: "123") {
//     id
//     status
//     total
//   }
// }

// Web can request everything
// query {
//   order(id: "123") {
//     id
//     status
//     total
//     items {
//       id
//       name
//       price
//       quantity
//     }
//     payment { status method }
//     shipping { status eta tracking }
//   }
// }

// Both queries hit the same resolver, which fetches from backend services
export const resolvers = {
  Query: {
    order: async (args: { id: string }, context: any) => {
      const order = await context.ordersService.getOrder(args.id)
      const payment = await context.paymentsService.getPayment(order.paymentId)
      const shipping = await context.shippingService.getTracking(args.id)

      return {
        id: order.id,
        status: order.status,
        total: order.total,
        items: order.items,
        payment,
        shipping
      }
    }
  }
}

Testing BFF With Contract Tests

BFF sits between client and backend. Test both sides:

// src/mobile-bff/__tests__/order-details.test.ts
import { PactV3 } from '@pact-foundation/pact'

describe('Mobile BFF - Order Details', () => {
  // Test BFF's contract with its clients (mobile app)
  describe('Client contract', () => {
    it('returns minimal order structure for mobile', async () => {
      const response = await request(app)
        .get('/orders/123')
        .set('Authorization', 'Bearer token')

      expect(response.status).toBe(200)
      expect(response.body).toEqual({
        id: expect.any(String),
        status: expect.any(String),
        total: expect.any(Number),
        items: expect.any(Array),
        payment: expect.any(Object),
        shipping: expect.any(Object) // Optimized structure
      })

      // Mobile doesn't get backend's full schema
      expect(response.body).not.toHaveProperty('internalNotes')
      expect(response.body).not.toHaveProperty('costBreakdown')
    })
  })

  // Test BFF's integration with backend services
  describe('Backend integration', () => {
    it('aggregates data from multiple services', async () => {
      const ordersServiceMock = jest.fn()
        .mockResolvedValue({ id: '123', status: 'confirmed', total: 99.99 })

      const paymentsServiceMock = jest.fn()
        .mockResolvedValue({ status: 'completed', method: 'card' })

      const shippingServiceMock = jest.fn()
        .mockResolvedValue({ status: 'shipped', eta: '2026-03-20' })

      const bff = new OrderDetailsBFF(
        ordersServiceMock,
        paymentsServiceMock,
        shippingServiceMock
      )

      const result = await bff.getOrderDetails('123')

      expect(ordersServiceMock).toHaveBeenCalledWith('123')
      expect(paymentsServiceMock).toHaveBeenCalled()
      expect(shippingServiceMock).toHaveBeenCalled()

      expect(result).toEqual({
        id: '123',
        status: 'confirmed',
        total: 99.99,
        payment: { status: 'completed', method: 'card' },
        shipping: { status: 'shipped', eta: '2026-03-20' }
      })
    })
  })
})

BFF in Next.js API Routes

Next.js API routes ARE your BFF—a natural place to aggregate backend services:

// pages/api/orders/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { ordersService, paymentsService, shippingService } from '@/lib/services'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  try {
    const { id } = req.query
    const userId = req.user?.id // From middleware

    // BFF aggregates from services
    const [order, payment, shipping] = await Promise.all([
      ordersService.getOrder(id as string, userId),
      paymentsService.getPaymentStatus(id as string),
      shippingService.getTracking(id as string)
    ])

    res.status(200).json({
      id: order.id,
      status: order.status,
      total: order.total,
      items: order.items,
      payment,
      shipping
    })
  } catch (error) {
    res.status(500).json({ error: 'Failed to load order' })
  }
}

Checklist

  • Each client (web, mobile, admin) has a dedicated BFF
  • BFFs aggregate from core services, don't duplicate logic
  • Response payloads optimized per client type
  • Authentication handled in BFF, delegated to backend services
  • Backend called with service account credentials
  • Parallel requests used instead of sequential
  • Caching implemented for frequently-accessed data
  • Both client and backend contracts tested
  • No business logic lives in BFF layer
  • Performance monitoring alerts on slow aggregations

Conclusion

A Backend for Frontend isn't an extra layer of complexity—it's the layer that simplifies each client's experience. Mobile gets minimal payloads, web gets rich data, admin gets operational visibility, all from the same backend services. Each team can optimize independently without compromising others.