As a developer, I’m constantly looking for ways to bridge the gap between complex backend architectures and seamless user experiences. If you are dealing with microservices or legacy APIs, you’ve likely faced the classic "Data Fetching Headache."
The Problem: Overfetching & Underfetching
We’ve all been there: your frontend needs a simple user dashboard, but the legacy API returns a massive 2MB JSON with fields you don't need (Overfetching). Or worse, you have to hit four different endpoints just to display a single page (Underfetching).
This creates latency, drains mobile batteries, and complicates your frontend logic.
The Solution: The BFF Pattern
The Backend-for-Frontend (BFF) pattern acts as a specialized intermediary. It aggregates multiple API calls, filters out the "noise," and delivers a clean, optimized payload specifically tailored for the UI.
Why Next.js is the "Built-in" BFF
If you are using Next.js (especially with the App Router), you already have a powerful BFF layer without needing to spin up a separate Express or Go server.
Here is the professional way to structure this: Service -> Route Handler -> Component.
1. The Service (The "BFF Logic")
This file handles the "dirty work": fetching from legacy systems, using private keys, and cleaning the data. This stays strictly on the server.
// services/apiService.ts
export async function getProductDashboard(productId: string) {
const API_URL = process.env.EXTERNAL_API_URL;
const API_KEY = process.env.API_SECRET_KEY; // Securely stored on the server
const res = await fetch(`${API_URL}/products/${productId}`, {
headers: { 'Authorization': `Bearer ${API_KEY}` },
next: { revalidate: 3600 } // ISR: Cache for 1 hour
});
if (!res.ok) throw new Error('Failed to fetch from Legacy API');
const rawData = await res.json();
// BFF Logic: Transform and "clean" the data for the UI
// We only return what the frontend actually uses.
return {
name: rawData.title.toUpperCase(),
price: `$ ${rawData.price_usd}`,
stockStatus: rawData.inventory > 0 ? 'In Stock' : 'Out of Stock',
lastUpdate: new Date().toLocaleDateString()
};
}
2. The Route Handler (The Internal Bridge)
This acts as your own private API endpoint. It allows your Client Components to trigger data fetching without ever exposing the sensitive Legacy API.
// app/api/products/[id]/route.ts
import { NextResponse } from 'next/server';
import { getProductDashboard } from '@/services/apiService';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
// Calling our internal service
const data = await getProductDashboard(params.id);
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
3. The Client Component (The Consumer)
The UI remains clean and decoupled. It calls your BFF endpoint, receiving the "perfect" object.
'use client';
import { useState } from 'react';
export default function ProductQuickView({ id }: { id: string }) {
const [data, setData] = useState(null);
const loadDetails = async () => {
// We fetch from our INTERNAL Route Handler, not the legacy API!
const res = await fetch(`/api/products/${id}`);
const result = await res.json();
setData(result);
};
return (
<div className="p-4 border rounded">
<button
onClick={loadDetails}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Quick View Product
</button>
{data && (
<div className="mt-4">
<h3>{data.name}</h3>
<p>Price: <strong>{data.price}</strong></p>
<p>Status: {data.stockStatus}</p>
</div>
)}
</div>
);
}
Why This Matters
- Security: Your
API_SECRET_KEYnever leaks to the browser. - Abstraction: If the legacy API changes its JSON structure tomorrow, you only update the Service. Your Route Handlers and Components stay exactly the same.
- Performance: You reduce the payload size significantly, which is critical for high-conversion e-commerce sites.
Final Thoughts
I’m Carlos, a senior developer and founder of Converte based in Florianópolis, Brazil. In my experience, choosing the right architecture isn't just about "clean code"—it’s about performance and results.
Using Next.js as a BFF allows us to deliver lightning-fast interfaces while keeping the codebase maintainable. Whether I’m catching waves here in Floripa or building complex digital solutions, the goal is always the same: agility and balance.
How about you? Do you prefer building a separate API layer, or are you leveraging Next.js's native server capabilities?
Top comments (0)