We are building a fintech product. The backend is a set of microservices — each service does one job, like handling payments, managing users, or dealing with authentication.
For the frontend, we chose Next.js.
But here is the interesting part: Next.js ended up doing two jobs in our architecture.
- It renders the UI that the user sees — pages, components, everything visual.
- It also acts as a Backend for Frontend (BFF) — a server layer that sits between the browser and our real microservices.
One Next.js project. Two responsibilities. This is the story of why I made that call, what it gave us, and what it cost us.
What Does "Next.js as BFF" Actually Mean?
Next.js is not just a frontend framework. It also lets you write API routes — real server-side code that runs on a Node.js server, not in the browser.
So our Next.js project has two parts living side by side:
/app
/dashboard ← React pages (frontend)
/profile ← React pages (frontend)
/app/api
/auth ← API route (BFF — calls Auth microservice)
/payments ← API route (BFF — calls Payments microservice)
/dashboard ← API route (BFF — combines data from 3 services)
The browser loads the React pages from Next.js. When those pages need data, they call the Next.js API routes — not the microservices directly. The API routes then talk to the real microservices behind the scenes.
Browser
↓ (loads page)
Next.js — React pages (frontend)
↓ (fetches data from)
Next.js — API routes (BFF)
↓ (calls)
Microservices (Auth / Payments / Users)
The browser never knows the microservices exist. It only ever talks to Next.js.
Why I Made This Decision
1. Security — Tokens Stay Out of the Browser
When a user logs in, the real Auth microservice gives back an access token. The normal thing to do is pass this token to the browser and store it in localStorage or memory.
But this is risky. If there is any XSS attack — malicious JavaScript running on your page — that token can be stolen and used to impersonate the user. In a fintech app, that is a serious problem.
Because Next.js API routes run on the server, they can handle the token before it ever reaches the browser. Here is the flow:
- Browser sends login details to the Next.js API route (
/api/auth/login) - The API route calls the real Auth microservice and gets the access token
- The API route converts that token into a secure, HTTP-only cookie
- The browser receives the cookie — it never sees the raw token
HTTP-only cookies cannot be read by JavaScript at all. Even if an attacker runs code in the browser, they cannot steal the token because the browser never had it.
// /app/api/auth/login/route.ts
export async function POST(req: Request) {
const { email, password } = await req.json();
// Call the real microservice
const { accessToken } = await authService.login(email, password);
// Convert to cookie — browser never sees the token
const response = NextResponse.json({ success: true });
response.cookies.set('session', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
return response;
}
This was the single biggest reason for the whole decision. Security first.
2. Dynamic Ads for External Websites
Our product shows custom ads on third-party websites. These ads are not static images — they need real-time data from our APIs to decide what to show to which user.
The problem: a third-party website cannot call our internal microservices directly. That would expose internal URLs, create CORS headaches, and open security holes.
With the Next.js BFF, the external website just calls a public-facing Next.js API route. That route handles all the internal API calls server-side and returns a clean response. The microservices stay completely hidden.
// /app/api/ads/route.ts — called by external websites
export async function GET(req: Request) {
const userId = getUserFromCookie(req);
// Internal calls — the external world never sees these
const adData = await adsService.getPersonalizedAd(userId);
return Response.json(adData);
}
No exposed microservice URLs. No CORS issues. The outside world only knows about Next.js.
3. Combining Multiple Services Into One Response
The dashboard page needs data from three different services — user profile, account balance, and recent transactions.
Without BFF — the browser makes 3 separate calls:
Browser → GET /users/me
Browser → GET /payments/balance
Browser → GET /transactions/recent
Three round trips. Slow. And the browser has to combine all the data itself.
With BFF — the browser makes 1 call to the Next.js API route, which calls all three services in parallel:
// /app/api/dashboard/route.ts
export async function GET(req: Request) {
const [user, balance, transactions] = await Promise.all([
userService.getMe(),
paymentsService.getBalance(),
transactionService.getRecent(),
]);
return Response.json({ user, balance, transactions });
}
One call from the browser. One clean response. The browser does not care how many microservices were involved.
The Difficulties
This is where I have to be honest. This decision was not free.
Double Integration — The Biggest Pain
Because Next.js is doing two jobs, every API endpoint has to be written twice:
- Once in the real microservice
- Once as a Next.js API route that proxies or transforms the microservice call
If the backend has 20 endpoints, I have to write 20 Next.js API routes too. Some of them are almost identical to the microservice they are calling.
Microservice: POST /auth/login → returns { accessToken }
Next.js route: POST /api/auth/login → calls microservice, sets cookie
This feels like redundancy — and it is. You are doing the integration work twice. This is the main trade-off of using your frontend framework as a BFF at the same time.
Next.js Becomes a Critical Point of Failure
Before this architecture:
Browser → Microservice
After this architecture:
Browser → Next.js → Microservice
If Next.js goes down, everything stops — the UI and the API layer both die at the same time. Before, a frontend bug would only break the UI. Now a Next.js server crash breaks the whole product.
This means the Next.js deployment needs to be treated with the same care as a backend service — proper monitoring, health checks, and failover.
Keeping the Two Layers in Sync
When a microservice changes its API — a new field, a renamed key, a different response structure — the Next.js layer also needs to be updated. This could easily become a mess, but we have a clear process that keeps things manageable.
Here is how it works in practice:
- The backend team updates their microservice and gives me updated Swagger or Postman documentation showing what changed.
- I update the Next.js API route (the BFF layer) to match the new microservice contract.
- I update the frontend — the React pages and components — to use the new data.
Steps 2 and 3 sound like extra work, but here is where having Next.js handle both roles actually helps: because the API routes and the React pages live in the same project, I can define a shared TypeScript interface for the response once and use it in both places.
// types/dashboard.ts — defined once, used everywhere in the same project
export interface DashboardResponse {
user: { id: string; name: string };
balance: number;
transactions: Transaction[];
}
// /app/api/dashboard/route.ts — BFF layer uses it
import { DashboardResponse } from '@/types/dashboard';
// /app/dashboard/page.tsx — frontend also uses the same type
import { DashboardResponse } from '@/types/dashboard';
If the microservice changes and I update the interface, TypeScript immediately shows me every place in the frontend that is now broken. I do not have to search manually — the compiler tells me exactly what to fix.
So yes, there is still sync work to do whenever the backend team ships a change. But the tight loop between the BFF routes and the UI — all in one project, all sharing types — makes that sync much faster than if they were separate codebases.
"Wait, Is This Frontend or Backend?"
New developers joining the project often get confused at first. They see API routes inside a Next.js project and are not sure what the boundary is.
"Why is there auth logic in the frontend project?"
"Should I add new business logic here or in the microservice?"
These questions are completely valid. The answer needs to be written down clearly — the Next.js API routes are the BFF layer, not a second backend. They handle session management, data aggregation, and the public API surface. Business logic lives in the microservices.
Without clear documentation, this confusion slows down new team members.
What I Learned From My First Architecture Decision
This was the first time I made a real architectural call on a production product. Here is what I took away.
Next.js is genuinely good at this dual role. It was designed to run server-side code. API routes are first-class citizens. Using them as a BFF is not a hack — it is a well-supported pattern.
The security benefit is real and hard to get another way. The token-to-cookie conversion needs a server. If you are using Next.js for the frontend anyway, using its API routes for this is practical and keeps your stack simple.
The redundancy is the real cost. Every time I add a new microservice endpoint, I feel the double integration work. It is not a one-time pain. It is ongoing.
Document the "why", not just the "what". The code shows what the system does. Only documentation explains why Next.js is doing API work at all. Without that, every new developer has to rediscover the reasoning.
There is no perfect architecture. Every decision trades one problem for another. This decision traded simplicity for security and cleaner data access. Knowing that trade-off clearly — instead of just copying a pattern from a blog post — is what made it feel like a real decision.
Would I Do It Again?
Yes — for this project.
The security layer was non-negotiable for a fintech product. The dynamic ads feature needed a server in the middle. And combining multiple API calls into one response has kept the React components much simpler.
But if I was starting a small personal project? I would not add this complexity. The double integration work is a real ongoing cost, and it only makes sense when the benefits — security, abstraction, external API surface — actually justify it.
Have you used Next.js as both the frontend and the BFF in the same project? I would love to know how you handled the double integration problem — did you auto-generate the routes, or did you draw a strict line somewhere about what logic belongs where? Drop it in the comments.
Top comments (0)