The Danger of Direct Client-to-Microservice Communication
In the era of decentralized architecture, enterprise applications are often backed by dozens of isolated microservices. There is a microservice for Billing, one for User Profiles, another for Search, and a separate API for Recommendations. Historically, Single Page Applications (SPAs) built with React were tasked with orchestrating all of this logic directly from the user's browser. The frontend would fire off five separate HTTP requests to five different APIs just to render the dashboard.
This direct Client-to-Microservice architecture introduces severe security and performance flaws. First, it requires exposing internal microservice URLs directly to the public internet. Second, it leads to a nightmare of Cross-Origin Resource Sharing (CORS) configurations. Third, it often results in "over-fetching"—a mobile user downloading 5MB of raw JSON data when they only needed three specific fields. Finally, and most critically, it forces the browser to store highly sensitive authentication tokens (like JWTs) in LocalStorage, rendering them incredibly vulnerable to Cross-Site Scripting (XSS) attacks.
At Smart Tech Devs, we never expose our microservices directly to the browser. Instead, we implement the Backend-for-Frontend (BFF) Pattern utilizing the powerful server-side capabilities of the Next.js App Router.
Understanding the BFF Architecture
The BFF is a dedicated translation layer that sits strictly between the client browser and your internal microservices. It is a backend server that exists solely to serve the specific needs of a single frontend interface. Instead of the browser talking to the Billing microservice, the browser talks to the Next.js BFF. The Next.js BFF then talks to the internal microservices securely over the private network, aggregates the data, strips out sensitive information, and hands a perfectly formatted, lightweight payload back to the browser.
Phase 1: Securing Authentication (HttpOnly Cookies)
The greatest security benefit of the BFF pattern is the eradication of client-side JWT storage. Because the Next.js App Router has a Node.js server component, it can act as an authenticating proxy.
When a user logs in, the Next.js BFF exchanges credentials with your Auth Microservice. Instead of handing the resulting JWT back to the React client, the BFF intercepts the JWT and stuffs it into an encrypted, HttpOnly, Secure cookie. This cookie cannot be read by JavaScript under any circumstances, entirely neutralizing XSS token theft.
// app/api/auth/login/route.ts (Next.js Route Handler acting as BFF)
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email, password } = await request.json();
// 1. The BFF communicates with the internal, isolated Auth Microservice
const authResponse = await fetch('http://internal-auth-service.local/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!authResponse.ok) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const { accessToken } = await authResponse.json();
// 2. The BFF creates the response
const response = NextResponse.json({ success: true });
// 3. The BFF secures the JWT in an HttpOnly cookie.
// The client browser NEVER sees the actual token string.
response.cookies.set({
name: 'enterprise_session',
value: accessToken,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 3600 // 1 hour
});
return response;
}
Phase 2: Data Aggregation and Payload Masking
Now that the client is authenticated via a secure cookie, the browser needs data to render the dashboard. Instead of the client making three separate API calls, it makes one call to a Next.js Server Component or Server Action.
The Next.js server extracts the JWT from the cookie, fans out multiple requests to the internal microservices in parallel, aggregates the data, strips out unnecessary database IDs and internal metadata (masking), and returns a single, optimized object to the UI.
// app/dashboard/page.tsx (Next.js Server Component acting as BFF layer)
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
async function fetchDashboardData() {
// Extract the secure token stored by our BFF login route
const token = cookies().get('enterprise_session')?.value;
if (!token) redirect('/login');
const headers = { 'Authorization': `Bearer ${token}` };
// Fan-out pattern: Fetch from multiple internal microservices simultaneously
const [userResponse, billingResponse] = await Promise.all([
fetch('http://internal-user-service.local/api/me', { headers }),
fetch('http://internal-billing-service.local/api/subscription', { headers })
]);
const rawUser = await userResponse.json();
const rawBilling = await billingResponse.json();
// Data Masking and Aggregation: Only send EXACTLY what the UI needs.
// This prevents over-fetching and hides internal API structures.
return {
name: rawUser.first_name + ' ' + rawUser.last_name,
email: rawUser.email,
subscriptionStatus: rawBilling.data.plan.is_active ? 'Active' : 'Expired',
renewalDate: rawBilling.data.next_billing_cycle
};
}
export default async function DashboardPage() {
const dashboardData = await fetchDashboardData();
return (
<main className="p-8">
<h1>Welcome, {dashboardData.name}</h1>
<p>Status: {dashboardData.subscriptionStatus}</p>
</main>
);
}
The Engineering ROI
Implementing the Backend-for-Frontend pattern in Next.js completely reshapes the security and performance profile of your enterprise application. By routing all client traffic through the Next.js server, you permanently solve CORS issues, as the browser only ever communicates with the Next.js origin domain. You drastically reduce bandwidth consumption on mobile devices by aggregating multiple API calls into a single, perfectly tailored payload. Most critically, you achieve enterprise-grade security by keeping your JWTs locked safely inside HttpOnly cookies and keeping your internal microservice URLs completely hidden from the public internet.
Top comments (1)
Solid writeup of the pattern. One thing worth adding, because it is where I most often see a correct BFF still leak: the guarantee is not "the code is in a route handler", it is "the bundler did not pull this into the client graph". Those are different claims, and only the second one is testable.
Two ways the boundary quietly moves in the App Router:
A server module imported by a client component. You keep the internal URL in
lib/internal-api.ts, correctly server-side. Later a client component imports a type or a small helper from that file. The bundler follows the import and the module comes along, internal hostnames included. The file is still "server code" by intent, and it is in the browser by build output. Nothing in review flags it, because each file looks right in isolation — the bug lives in the import graph.The NEXT_PUBLIC escape hatch. A client component throws on an undefined env var, and the fastest fix that makes the error stop is renaming it with the prefix. That prefix is not a convention, it is an instruction to inline the value into the client bundle at build time. The error goes away precisely because the secret is now genuinely in the browser.
So after wiring up the BFF, I would verify rather than assume:
Anything that comes back from
.next/staticis shipped, regardless of which folder the source file lives in.One architectural note on the fan-out: the BFF forwards the user's JWT to each internal service, so those services are still the authorization boundary. If any of them trusts the token's identity but not its scope — returns rows by id without checking the caller owns them — the BFF has aggregated an authorization bug into a clean-looking payload. Worth asserting per service that a token from user A cannot fetch user B's record, rather than assuming the edge already handled it.
Related failure in the same family, on the database side: dev.to/cekuu35/your-supabase-rls-p...