From BFF to Production: How I Built an API Layer That Frontend Teams Actually Love

A real-world walkthrough of designing a Backend for Frontend layer: why we skipped GraphQL, the patterns we used, and what we learned deploying it to production.

Why We Needed a BFF in the First Place

We were three frontend engineers maintaining a React SPA that spoke directly to eight different microservices. Authentication tokens passed through three layers. A single page load triggered up to six parallel API calls. Product changed something in the pricing service and our UI broke silently.

The codebase was honest about the problem: fetchUser, fetchPricing, fetchLeads, fetchCampaigns all in the same component, each with their own error handling, retry logic, and loading state. It was the system telling us it needed a seam.

A Backend for Frontend (BFF) is a dedicated API layer owned by the frontend team, purpose-built for the needs of a specific UI. Not a general-purpose API. It's a translation layer that speaks the language your frontend actually wants.

Why We Didn't Use GraphQL

The obvious move would have been GraphQL. Schema stitching, federation, a single endpoint. It sounds clean on paper, but for our situation it had real costs:

  • Learning curve across the team. We had two mid-level engineers who would own this long-term. Adding a new query language and resolver model meant months before they were productive.
  • Overfetching the solution. We had fewer than 15 upstream endpoints. GraphQL's strength is large, heterogeneous graphs with many clients. We were one client, one SPA.
  • Tooling overhead. Code generation, schema registry, persisted queries. For a team of three, that's months of infrastructure work before shipping the first fix.

We chose REST with a deliberate aggregation pattern instead. The BFF exposed exactly the endpoints our UI needed, nothing more.

The Architecture

Frontend (React SPA)
       │
       ▼
  BFF Layer (Node.js / Express)
  ┌──────────────────────────┐
  │  /api/lead-dashboard     │──► Leads Service
  │  /api/campaign-overview  │──► Campaign Service
  │  /api/user-profile       │──► Auth Service + User Service
  └──────────────────────────┘

The BFF runs as a lightweight Node.js service on Google Cloud Run, sharing the same GCP project as the microservices. Internal networking is fast and there are no egress costs between services.

Route Aggregation

Each BFF route is responsible for one screen's worth of data. Here's a simplified version of the lead dashboard endpoint:

// bff/routes/lead-dashboard.ts
import { Router } from 'express';
import { fetchLeads } from '../services/leads';
import { fetchCampaignMeta } from '../services/campaigns';
import { fetchUserContext } from '../services/auth';
 
const router = Router();
 
router.get('/lead-dashboard', async (req, res) => {
  const userId = req.user.id;
 
  const [leads, campaignMeta, userContext] = await Promise.allSettled([
    fetchLeads(userId),
    fetchCampaignMeta(userId),
    fetchUserContext(userId),
  ]);
 
  res.json({
    leads: leads.status === 'fulfilled' ? leads.value : [],
    campaign: campaignMeta.status === 'fulfilled' ? campaignMeta.value : null,
    user: userContext.status === 'fulfilled' ? userContext.value : null,
    errors: [leads, campaignMeta, userContext]
      .filter(r => r.status === 'rejected')
      .map(r => (r as PromiseRejectedResult).reason?.message),
  });
});
 
export default router;

Notice Promise.allSettled instead of Promise.all. This is intentional. Partial failures are surfaced to the frontend as data, not thrown as exceptions. The UI decides what to render when campaign is null. This pattern alone eliminated a whole class of full-page error states.

Response Shaping

Upstream services return data in their own canonical shape. The BFF's job is to translate:

// Upstream lead service returns
{ lead_id: string, creation_date: string, status_code: number }
 
// BFF shapes it for the UI
{ id: string, createdAt: Date, status: 'new' | 'contacted' | 'converted' }

This means the frontend never knows or cares when the leads service renames a field. The BFF absorbs that change.

Versioning Strategy

We version the BFF by route prefix: /api/v1/.... When we need a breaking change we ship /api/v2/... alongside the old route, migrate the frontend, then delete v1 after two releases.

This is simple, boring, and works. We considered header-based versioning but it made local development harder to reason about.

Authentication

The BFF validates JWT tokens from our identity provider before forwarding any request upstream. Upstream services trust the BFF and don't re-validate tokens themselves. The BFF is the single authentication boundary.

// middleware/auth.ts
import jwt from 'jsonwebtoken';
 
export function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });
 
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET) as JwtPayload;
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

What We Shipped to Production

After three months in production on Google Cloud Run:

  • Page load API calls dropped from 6 to 1 for the main dashboard
  • Time to First Meaningful Paint improved ~40% (less waterfall, single round trip)
  • Frontend engineers stopped caring about microservice boundaries, which is exactly the goal
  • Zero downstream breakages when three upstream services changed their response schemas

What I Would Do Differently

Add request tracing from day one. We hacked in correlation IDs four months in when debugging was painful. Start with x-request-id headers flowing through every upstream call from the first commit.

Write integration tests for the aggregation logic, not just unit tests. We unit-tested the shape transformers but the first time a downstream service added a required field we only caught it in staging. Contract testing with something like Pact would close that gap.

Document which upstream services each BFF route depends on. A simple comment block pays off enormously when you need to understand blast radius for a service outage.

Conclusion

A BFF is not about technology. It's about ownership. Frontend teams move faster when they control the API contract for their own UI. GraphQL is a powerful tool for the right scale, but at our scale, a thin Node.js layer with deliberate aggregation routes gave us everything we needed with a fraction of the complexity.

If your frontend team is managing multi-service API orchestration inside components, the BFF pattern is worth your next engineering spike.