Backend-for-Frontend (BFF) Pattern
Learn how to implement the BFF pattern in Nuxt to aggregate multiple APIs, transform data, and provide a unified interface for your frontend.
Why BFF Pattern
- ✅ Simplify frontend by handling complexity on the server
- ✅ Aggregate multiple microservices into single endpoints
- ✅ Reduce network requests from client
- ✅ Transform backend data to frontend-friendly formats
- ✅ Centralize authentication and error handling
Understanding BFF Architecture
Frontend (Nuxt App)
↓
BFF Layer (Nuxt Server)
↓ ↓ ↓
API1 API2 API3 (Microservices)
The BFF layer acts as an intermediary that:
- Aggregates data from multiple sources
- Transforms data structures
- Handles authentication tokens
- Provides unified error responses
Basic BFF Setup
Server API Handler
// server/api/dashboard.get.ts
export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
if (!session) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
})
}
// Aggregate data from multiple services
const [userProfile, orders, analytics] = await Promise.all([
fetchUserProfile(session.userId),
fetchUserOrders(session.userId),
fetchUserAnalytics(session.userId)
])
// Transform to frontend format
return {
user: {
name: userProfile.firstName + ' ' + userProfile.lastName,
email: userProfile.email,
avatar: userProfile.avatarUrl
},
recentOrders: orders.slice(0, 5).map(order => ({
id: order.id,
total: order.totalAmount,
date: order.createdAt,
status: mapOrderStatus(order.status)
})),
stats: {
totalSpent: analytics.totalRevenue,
orderCount: analytics.orderCount,
averageOrder: analytics.averageOrderValue
}
}
})
API Client Abstraction
// server/utils/apiClient.ts
interface ApiClientConfig {
baseURL: string
headers?: Record<string, string>
}
export class ApiClient {
private baseURL: string
private headers: Record<string, string>
constructor(config: ApiClientConfig) {
this.baseURL = config.baseURL
this.headers = config.headers || {}
}
async get<T>(path: string, options: any = {}): Promise<T> {
const response = await $fetch<T>(`${this.baseURL}${path}`, {
method: 'GET',
headers: this.headers,
...options
})
return response
}
async post<T>(path: string, body: any, options: any = {}): Promise<T> {
const response = await $fetch<T>(`${this.baseURL}${path}`, {
method: 'POST',
headers: this.headers,
body,
...options
})
return response
}
async patch<T>(path: string, body: any, options: any = {}): Promise<T> {
const response = await $fetch<T>(`${this.baseURL}${path}`, {
method: 'PATCH',
headers: this.headers,
body,
...options
})
return response
}
async delete<T>(path: string, options: any = {}): Promise<T> {
const response = await $fetch<T>(`${this.baseURL}${path}`, {
method: 'DELETE',
headers: this.headers,
...options
})
return response
}
}
// Create service clients
export const userServiceClient = new ApiClient({
baseURL: process.env.USER_SERVICE_URL || 'http://localhost:3001',
headers: {
'X-API-Key': process.env.USER_SERVICE_API_KEY || ''
}
})
export const orderServiceClient = new ApiClient({
baseURL: process.env.ORDER_SERVICE_URL || 'http://localhost:3002',
headers: {
'X-API-Key': process.env.ORDER_SERVICE_API_KEY || ''
}
})
export const analyticsServiceClient = new ApiClient({
baseURL: process.env.ANALYTICS_SERVICE_URL || 'http://localhost:3003',
headers: {
'X-API-Key': process.env.ANALYTICS_SERVICE_API_KEY || ''
}
})
Data Aggregation
Fetching from Multiple Services
// server/api/user/[id]/complete-profile.get.ts
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({
statusCode: 400,
message: 'User ID required'
})
}
try {
// Fetch from multiple services in parallel
const [
userBasic,
userPreferences,
subscription,
recentActivity
] = await Promise.all([
userServiceClient.get(`/users/${userId}`),
userServiceClient.get(`/users/${userId}/preferences`),
billingServiceClient.get(`/subscriptions/user/${userId}`),
activityServiceClient.get(`/activities/user/${userId}?limit=10`)
])
// Aggregate and transform
return {
id: userId,
profile: {
...userBasic,
preferences: userPreferences
},
subscription: {
plan: subscription.planName,
status: subscription.status,
renewsAt: subscription.renewalDate
},
recentActivity: recentActivity.map(transformActivity)
}
} catch (error) {
throw createError({
statusCode: 500,
message: 'Failed to fetch complete profile',
cause: error
})
}
})
function transformActivity(activity: any) {
return {
type: activity.activityType,
description: activity.description,
timestamp: activity.createdAt,
metadata: activity.metadata
}
}
Handling Partial Failures
// server/api/dashboard-resilient.get.ts
export default defineEventHandler(async (event) => {
const userId = await getUserId(event)
// Use Promise.allSettled for resilience
const [userResult, ordersResult, analyticsResult] = await Promise.allSettled([
fetchUserProfile(userId),
fetchUserOrders(userId),
fetchUserAnalytics(userId)
])
return {
user: userResult.status === 'fulfilled' ? userResult.value : null,
orders: ordersResult.status === 'fulfilled' ? ordersResult.value : [],
analytics: analyticsResult.status === 'fulfilled' ? analyticsResult.value : {
totalSpent: 0,
orderCount: 0
},
errors: {
user: userResult.status === 'rejected' ? userResult.reason : null,
orders: ordersResult.status === 'rejected' ? ordersResult.reason : null,
analytics: analyticsResult.status === 'rejected' ? analyticsResult.reason : null
}
}
})
Data Transformation
Response Mappers
// server/utils/mappers/orderMapper.ts
interface BackendOrder {
order_id: string
customer_id: string
total_amount_cents: number
order_status: string
created_timestamp: number
line_items: BackendLineItem[]
}
interface FrontendOrder {
id: string
customerId: string
total: number
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'
createdAt: string
items: FrontendLineItem[]
}
export function mapOrderToFrontend(order: BackendOrder): FrontendOrder {
return {
id: order.order_id,
customerId: order.customer_id,
total: order.total_amount_cents / 100,
status: mapOrderStatus(order.order_status),
createdAt: new Date(order.created_timestamp * 1000).toISOString(),
items: order.line_items.map(mapLineItemToFrontend)
}
}
function mapOrderStatus(status: string): FrontendOrder['status'] {
const statusMap: Record<string, FrontendOrder['status']> = {
'PENDING': 'pending',
'PROCESSING': 'processing',
'SHIPPED': 'shipped',
'DELIVERED': 'delivered',
'CANCELLED': 'cancelled'
}
return statusMap[status] || 'pending'
}
export function mapOrderToBackend(order: Partial<FrontendOrder>): Partial<BackendOrder> {
return {
order_id: order.id,
customer_id: order.customerId,
total_amount_cents: order.total ? Math.round(order.total * 100) : undefined,
order_status: order.status?.toUpperCase(),
line_items: order.items?.map(mapLineItemToBackend)
}
}
Using Mappers
// server/api/orders/[id].get.ts
import { mapOrderToFrontend } from '~/server/utils/mappers/orderMapper'
export default defineEventHandler(async (event) => {
const orderId = getRouterParam(event, 'id')
const backendOrder = await orderServiceClient.get(`/orders/${orderId}`)
// Transform backend format to frontend format
return mapOrderToFrontend(backendOrder)
})
Unified Error Handling
Error Response Structure
// server/utils/errors.ts
export interface ApiError {
statusCode: number
message: string
errors?: Record<string, string[]>
timestamp: string
path: string
}
export function createApiError(
statusCode: number,
message: string,
errors?: Record<string, string[]>
): ApiError {
return {
statusCode,
message,
errors,
timestamp: new Date().toISOString(),
path: ''
}
}
export function handleServiceError(error: any, serviceName: string) {
console.error(`${serviceName} error:`, error)
// Map different error types
if (error.statusCode === 404) {
throw createError({
statusCode: 404,
message: `Resource not found in ${serviceName}`
})
}
if (error.statusCode === 401 || error.statusCode === 403) {
throw createError({
statusCode: 401,
message: 'Authentication required'
})
}
// Default server error
throw createError({
statusCode: 500,
message: `${serviceName} service unavailable`,
cause: error
})
}
Global Error Handler
// server/middleware/error-handler.ts
export default defineEventHandler((event) => {
event.node.res.on('finish', () => {
const statusCode = event.node.res.statusCode
if (statusCode >= 400) {
console.error('Error response:', {
path: event.node.req.url,
method: event.node.req.method,
statusCode,
timestamp: new Date().toISOString()
})
}
})
})
Authentication & Authorization
Token Forwarding
// server/utils/auth.ts
export async function getAuthToken(event: any): Promise<string | null> {
const authHeader = getHeader(event, 'authorization')
if (!authHeader?.startsWith('Bearer ')) {
return null
}
return authHeader.substring(7)
}
export async function forwardAuthToService<T>(
event: any,
serviceClient: ApiClient,
path: string,
options: any = {}
): Promise<T> {
const token = await getAuthToken(event)
if (!token) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
})
}
return serviceClient.get<T>(path, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
})
}
Using Auth Forwarding
// server/api/profile.get.ts
export default defineEventHandler(async (event) => {
const profile = await forwardAuthToService(
event,
userServiceClient,
'/me'
)
return profile
})
Caching Strategy
In-Memory Cache
// server/utils/cache.ts
interface CacheEntry<T> {
data: T
expiresAt: number
}
class MemoryCache {
private cache = new Map<string, CacheEntry<any>>()
set<T>(key: string, data: T, ttlSeconds: number = 300) {
this.cache.set(key, {
data,
expiresAt: Date.now() + ttlSeconds * 1000
})
}
get<T>(key: string): T | null {
const entry = this.cache.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) {
this.cache.delete(key)
return null
}
return entry.data
}
clear() {
this.cache.clear()
}
delete(key: string) {
this.cache.delete(key)
}
}
export const cache = new MemoryCache()
Using Cache in BFF
// server/api/products/featured.get.ts
import { cache } from '~/server/utils/cache'
export default defineEventHandler(async (event) => {
const cacheKey = 'featured-products'
// Try cache first
const cached = cache.get(cacheKey)
if (cached) {
return cached
}
// Fetch from service
const products = await productServiceClient.get('/products/featured')
// Cache for 5 minutes
cache.set(cacheKey, products, 300)
return products
})
Request Batching
Batch Endpoint
// server/api/batch.post.ts
export default defineEventHandler(async (event) => {
const requests = await readBody<Array<{
id: string
method: string
path: string
body?: any
}>>(event)
const results = await Promise.allSettled(
requests.map(async (req) => {
try {
const response = await $fetch(req.path, {
method: req.method,
body: req.body,
headers: getHeaders(event)
})
return {
id: req.id,
status: 'success',
data: response
}
} catch (error: any) {
return {
id: req.id,
status: 'error',
error: {
message: error.message,
statusCode: error.statusCode
}
}
}
})
)
return results.map((result, index) =>
result.status === 'fulfilled' ? result.value : {
id: requests[index].id,
status: 'error',
error: { message: 'Request failed' }
}
)
})
Best Practices
✅ Do's
- ✅ Aggregate related data in single BFF endpoint
- ✅ Transform data to frontend-friendly formats
- ✅ Handle partial failures gracefully
- ✅ Cache responses when appropriate
- ✅ Forward authentication tokens securely
- ✅ Log errors with context for debugging
- ✅ Use TypeScript for type safety across layers
❌ Don'ts
- ❌ Don't expose internal service structures
- ❌ Don't make BFF endpoints too generic
- ❌ Don't skip error handling for external services
- ❌ Don't cache sensitive user data
- ❌ Don't create N+1 query problems
- ❌ Don't forget to set proper timeout values
Advanced Patterns
GraphQL-style Field Selection
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
const query = getQuery(event)
const fields = (query.fields as string)?.split(',') || []
const tasks: Promise<any>[] = []
const result: any = { id: userId }
if (fields.includes('profile') || fields.length === 0) {
tasks.push(
userServiceClient.get(`/users/${userId}`)
.then(data => { result.profile = data })
)
}
if (fields.includes('orders')) {
tasks.push(
orderServiceClient.get(`/orders?userId=${userId}`)
.then(data => { result.orders = data })
)
}
if (fields.includes('analytics')) {
tasks.push(
analyticsServiceClient.get(`/analytics/user/${userId}`)
.then(data => { result.analytics = data })
)
}
await Promise.all(tasks)
return result
})
The BFF pattern simplifies frontend development by providing a tailored API layer that aggregates, transforms, and optimizes data from multiple backend services.