Loading…
Loading…
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
Tags
In this tutorial, you will build a Backend-for-Frontend using Next.js and Node.js that aggregates upstream APIs, centralizes authentication, shapes responses for the UI, and reduces frontend complexity. A BFF is most valuable when your frontend has orchestration needs that backend services should not own directly.
Before you start, you should be comfortable with:
You do not need a microservices platform to benefit from this pattern. Even a mid-sized product with a few backend services can justify a BFF if the frontend is doing too much coordination on its own.
A Backend-for-Frontend is not just another API. It is a frontend-oriented orchestration layer.
In practice, that means the BFF usually owns:
It usually should not own:
The simplest mental model is this: domain services own business capabilities, and the BFF owns delivery of those capabilities to one frontend surface.
In a Next.js App Router application, route handlers are a practical place to start.
// app/api/dashboard/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const [profileRes, notificationsRes] = await Promise.all([
fetch(`${process.env.USER_SERVICE_URL}/me`, {
headers: { Authorization: `Bearer ${process.env.INTERNAL_TOKEN}` },
cache: "no-store",
}),
fetch(`${process.env.NOTIFICATION_SERVICE_URL}/notifications`, {
headers: { Authorization: `Bearer ${process.env.INTERNAL_TOKEN}` },
cache: "no-store",
}),
]);
if (!profileRes.ok || !notificationsRes.ok) {
return NextResponse.json(
{ message: "Failed to load dashboard data" },
{ status: 502 }
);
}
const [profile, notifications] = await Promise.all([
profileRes.json(),
notificationsRes.json(),
]);
return NextResponse.json({
user: {
id: profile.id,
name: profile.name,
role: profile.role,
},
unreadNotifications: notifications.items.filter((item: any) => !item.read),
});
}This route is not just proxying. It is:
That is the core value of the BFF pattern.
If you leave all orchestration inside route handlers, the BFF becomes messy fast. A cleaner pattern is to keep handlers thin and move integration logic into application services.
// lib/bff/dashboard-service.ts
type DashboardData = {
user: {
id: string;
name: string;
role: string;
};
unreadNotifications: Array<{
id: string;
title: string;
}>;
};
export async function getDashboardData(token: string): Promise<DashboardData> {
const [profileRes, notificationsRes] = await Promise.all([
fetch(`${process.env.USER_SERVICE_URL}/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}),
fetch(`${process.env.NOTIFICATION_SERVICE_URL}/notifications`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}),
]);
if (!profileRes.ok) {
throw new Error("Failed to fetch user profile");
}
if (!notificationsRes.ok) {
throw new Error("Failed to fetch notifications");
}
const [profile, notifications] = await Promise.all([
profileRes.json(),
notificationsRes.json(),
]);
return {
user: {
id: profile.id,
name: profile.name,
role: profile.role,
},
unreadNotifications: notifications.items
.filter((item: any) => !item.read)
.map((item: any) => ({
id: item.id,
title: item.title,
})),
};
}Then the route handler becomes much easier to test and reason about.
// app/api/dashboard/route.ts
import { NextResponse } from "next/server";
import { getDashboardData } from "@/lib/bff/dashboard-service";
export async function GET() {
try {
const token = process.env.INTERNAL_TOKEN!;
const data = await getDashboardData(token);
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ message: "Unable to load dashboard data" },
{ status: 502 }
);
}
}One of the strongest reasons to use a BFF is auth centralization. Instead of making the frontend manage multiple downstream tokens, the BFF can validate the user session once and call upstream services on the user's behalf.
A common pattern looks like this:
That reduces token sprawl in the client and gives you a clean place to enforce access rules.
A mistake many teams make is returning backend-shaped payloads directly through the BFF. That defeats the point.
The BFF should optimize for:
For example, if your UI needs a single accountSummary object, the BFF can combine billing, user, and usage data into one response. That is often better than making the frontend assemble it itself.
Because the BFF sits close to the UI, it is a good place for frontend-oriented caching decisions. But caching needs discipline.
Good candidates:
Bad candidates:
If you cache through the BFF, define freshness and invalidation rules explicitly. Otherwise, the BFF becomes a source of stale and confusing behavior.
A BFF often depends on multiple upstream systems. That means partial failure is normal, not exceptional.
A strong BFF design answers:
In many products, the best response is not full failure. It is degraded success.
return NextResponse.json({
user,
unreadNotifications: notifications ?? [],
warnings: notifications ? [] : ["Notifications are temporarily unavailable"],
});That gives the UI a graceful fallback path instead of a binary pass/fail outcome.
For a real BFF in Next.js and Node.js, a practical file layout looks like this:
app/
api/
dashboard/
route.ts
lib/
bff/
dashboard-service.ts
auth.ts
upstream-clients/
user-service.ts
notification-service.ts
validation/
dashboard-schema.tsThat structure keeps concerns separate:
If every frontend workaround, auth rule, and temporary transformation lands in the BFF without structure, the layer becomes harder to maintain than the frontend it was meant to simplify.
The BFF should coordinate and shape data, not become the permanent home of domain rules that should live in shared services.
If the BFF is just a thin proxy, you gain little. The value comes from translation, aggregation, and boundary control.
If upstream services change shape and the BFF does not validate responses, the frontend ends up breaking in confusing ways. Validate at the boundary whenever possible.
Use a BFF when:
Do not reach for a BFF automatically when:
Once the basic BFF is working, the next improvements usually are:
That is when a simple BFF starts becoming a dependable production boundary instead of just an API convenience layer.
A Backend-for-Frontend is a server layer designed specifically for a frontend application or channel. It aggregates data, shapes responses, enforces auth, and hides backend complexity from the UI.
Next.js works well with BFF architecture because route handlers, server components, and server actions can coordinate API calls and shape data close to the UI layer.
A BFF may be unnecessary for very small applications with simple backend APIs and limited orchestration needs. It adds a layer, so it should solve a real frontend coordination problem.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.
A practical implementation guide for adding OpenTelemetry to Next.js and Node.js apps, including traces, request flow visibility, and production diagnostics.