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

  1. ✅ Aggregate related data in single BFF endpoint
  2. ✅ Transform data to frontend-friendly formats
  3. ✅ Handle partial failures gracefully
  4. ✅ Cache responses when appropriate
  5. ✅ Forward authentication tokens securely
  6. ✅ Log errors with context for debugging
  7. ✅ Use TypeScript for type safety across layers

❌ Don'ts

  1. ❌ Don't expose internal service structures
  2. ❌ Don't make BFF endpoints too generic
  3. ❌ Don't skip error handling for external services
  4. ❌ Don't cache sensitive user data
  5. ❌ Don't create N+1 query problems
  6. ❌ 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.

Multi-step Forms
Create wizard-style forms with state management, progressive validation, and navigation for complex data entry workflows using the NuxtUI Stepper component.
Progressive Web Apps (PWA)
Transform your Nuxt application into a Progressive Web App with offline support, installability, and native-like features.
Files
Editor
Initializing WebContainer
Mounting files
Installing dependencies
Starting Nuxt server
Waiting for Nuxt to ready
Terminal