DEV Community

Cover image for Why Next.js is the Ultimate Tool for the BFF (Backend-for-Frontend) Pattern?
Carlos Rogerio Orioli
Carlos Rogerio Orioli

Posted on

Why Next.js is the Ultimate Tool for the BFF (Backend-for-Frontend) Pattern?

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()
  };
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why This Matters

  1. Security: Your API_SECRET_KEY never leaks to the browser.
  2. Abstraction: If the legacy API changes its JSON structure tomorrow, you only update the Service. Your Route Handlers and Components stay exactly the same.
  3. 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)