Field note / nextjs

publishedupdated 2026-05-04#nextjs#route-handlers#bff#api#backend-for-frontend

Next.js Route Handlers & BFF

How I use Route Handlers as a backend-for-frontend boundary: request shaping, auth-aware aggregation, streaming, and operational guardrails.

TL;DR

  • Route Handlers are the right tool when the UI needs a server boundary for auth-aware shaping, aggregation, streaming, or server-only credentials.
  • I use them as a BFF seam, not as a place to rebuild an entire backend inside the frontend repo.
  • The strongest Route Handlers project data for one client surface, enforce request boundaries, and stay obsessive about timeouts, observability, and failure shape.
  • If the handler becomes a pass-through proxy or a hidden domain service, I split responsibilities again.

Context

In larger React and Next.js apps, the browser often should not orchestrate everything directly. The UI usually needs:

  • one payload instead of three service calls
  • tenant and auth context injected server-side
  • secret-bearing downstream calls hidden from the client
  • streaming or edge-aware response control
  • response shaping that is specific to one frontend surface

That is where a backend-for-frontend earns its keep, and Route Handlers are a practical place to host that boundary when the surface belongs to the Next.js app itself.

The Approach

1. Use Route Handlers when the browser would otherwise get too smart

I reach for a Route Handler when I need:

  • aggregating multiple downstream calls
  • injecting auth or tenant context
  • normalizing response shapes
  • keeping private tokens off the client
  • converting backend failure noise into one stable UI contract
  • streaming data back with Web Request / Response primitives
The BFF owns orchestration and projection so the browser can stay focused on rendering and interaction.

2. Keep the handler contract UI-shaped, not backend-shaped

This is the difference between a useful BFF and a noisy proxy.

import { NextRequest } from "next/server"

export async function GET(request: NextRequest) {
  const tenantId = request.headers.get("x-tenant-id")
  const authHeader = request.headers.get("authorization")

  const [profileResponse, preferencesResponse] = await Promise.all([
    fetchProfile({ tenantId, authHeader }),
    fetchPreferences({ tenantId, authHeader }),
  ])

  if (!profileResponse.ok) {
    return Response.json({ message: "Unable to load profile" }, { status: 502 })
  }

  const profile = await profileResponse.json()
  const preferences = preferencesResponse.ok ? await preferencesResponse.json() : { theme: "system" }

  return Response.json({
    id: profile.id,
    name: profile.displayName,
    theme: preferences.theme,
    canEdit: profile.roles.includes("editor"),
  })
}

The browser gets the shape it needs. It does not need to know every downstream schema.

3. Put guardrails in the handler from day one

The easiest way to create BFF pain is to ship a “temporary” handler with no operational discipline.

My baseline checklist:

  • per-downstream timeout
  • request correlation id propagation
  • auth and tenant context verification
  • consistent error envelope
  • no accidental fan-out explosion
  • explicit runtime choice when the dependency stack requires Node features

For a slower downstream, I prefer partial degradation over full-page collapse when the product allows it.

4. Treat caching and streaming as first-class design choices

Current Next.js docs are explicit that Route Handlers are not cached by default, but GET handlers can opt into caching. That makes the design question clearer: does this handler represent user-specific live data, or a reusable cached read?

For personalized responses, I usually stay request-time.

For semi-static content, I make caching explicit.

export const dynamic = "force-static"

export async function GET() {
  const docs = await fetchPublishedDocs()
  return Response.json(docs)
}

And for streaming workloads, Route Handlers are a good fit because they already speak the Web Response API cleanly.

5. Do not hide real domain ownership inside the BFF

This is the line I try hard not to cross.

Good BFF logic:

  • projection
  • orchestration
  • auth-aware shaping
  • request batching for the UI

Bad BFF logic:

  • core domain invariants
  • complex pricing rules
  • ownership of cross-client business contracts
  • persistence workflows that belong in the platform service

If mobile, web, and partner clients all need the same mutation contract, it probably should not live only in a Next.js Route Handler.

Trade-offs

  • A Route Handler keeps the client lighter, but it adds an extra operational layer that still needs ownership, logs, and error design.
  • Centralizing UI projection on the server simplifies the browser, but careless BFF growth creates a shadow backend with weak service boundaries.
  • Route-local BFFs work well for frontend-owned surfaces, but they are the wrong place for a shared public API contract that many clients must consume.
  • Caching can lower latency dramatically, but one wrong assumption about personalization or invalidation creates subtle production bugs.

References