- Published on
The Backend for Frontend Pattern — Why Your Mobile and Web Apps Need Different APIs
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- Aggregation of Multiple Services
- Per-Client Payload Optimization
- Authentication Delegation
- BFF as Composition Layer (No Business Logic)
- When BFF Becomes a Bottleneck
- GraphQL as Universal BFF Alternative
- Testing BFF With Contract Tests
- BFF in Next.js API Routes
- Checklist
- Conclusion
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.