The BFF Pattern: Your API Token Has No Business in the Browser
DEV Community

The BFF Pattern: Your API Token Has No Business in the Browser

The Problem

You've got a Next.js app on one domain and your API on another. The login works, the dashboard fills up, everything looks fine. Then one day you open the Network tab and there it is: your access token, in plain text, in a request header the browser sent all by itself. At that point every line of JavaScript on the page can read it. Not just your code. The analytics snippet you added last week, the twelve transitive dependencies you've never opened, whatever an XSS bug manages to slip in. And you never really decided this. It just happened, because calling the API straight from the component was the path of least resistance, and nothing complained.

The browser is not a trusted client

The version that gets you here looks completely reasonable:

// โŒ a Client Component talking to your API
'use client';
export function Profile() {
  useEffect(() => {
    fetch('https://api.example.com/me', {
      headers: { authorization: `Bearer ${token}` },
    })
      .then((r) => r.json())
      .then(setProfile);
  }, []);
}

Look at what that actually signed you up for. The token is in JavaScript, so the protection an HttpOnly cookie would have given you is simply gone. The call is cross-origin, so you're now on the hook for CORS: preflight requests, an allowed-origins list to maintain, Access-Control-Allow-Credentials, and the quiet worry that you've opened it wider than you should. And because the request leaves from the browser, anyone with devtools can read your API's base URL, its routes, and how it expects to be authenticated. That's a decent map of your backend, handed out to every visitor.

What makes this hard to catch is that none of it breaks. It works in the demo, it works in production, it reviews clean. It just sits there as a liability until the day it stops being quiet.

The Backend-for-Frontend Pattern

The browser talks to your server. Your server talks to your API.

The Backend-for-Frontend pattern draws one line: the browser only ever talks to your Next.js server. The Next.js server talks to your API.

Browser -> Next.js (Server Actions / Route Handlers) -> API

The App Router was built for this. Server Actions and Route Handlers run on the server, where the session cookie is readable and the token never has to leave. The browser holds a single HttpOnly session cookie and nothing else. It doesn't know your API's URL. It can't read the token. And because everything is same-origin now, CORS disappears entirely.

This is not a new idea. It's the correct one. The trouble is what it costs to build by hand.

The BFF Is Where the Boring Code Lives

Move that fetch to the server and the boilerplate shows up immediately. You have to:

  • read the session cookie from the incoming request and forward it to the API,
  • take the API's Set-Cookie back on login and re-emit it on the Next.js origin,
  • set Content-Type: application/json for JSON bodies, but never for FormData or file uploads,
  • parse a Retry-After on a 429,
  • and decide what happens when the API is simply down, instead of leaking a raw transport error into a page render.

So every project grows its own version of this:

// โŒ the same 120 lines, rewritten in every project
export async function apiFetch(path: string, init: RequestInit = {}) {
  const session = (await cookies()).get('app_session')?.value;
  const headers = new Headers(init.headers);
  if (session) headers.set('cookie', `app_session=${session}`);
  if (shouldBeJson(init.body)) headers.set('content-type', 'application/json');
  let res: Response;
  try {
    res = await fetch(`${process.env.API_URL}${path}`, {
      ...init,
      headers,
      cache: 'no-store',
    });
  } catch (e) {
    // is this the API being down, or a real bug? better guess right...
  }
  // ...now re-emit Set-Cookie, parse the body, read Retry-After, and on and on
}

It's not hard. It's just fiddly, easy to get subtly wrong, and it has no business being rewritten in every repo.

Introducing @lepresk/next-bff-fetch

A server-only fetch client for the App Router that owns exactly that plumbing. Zero magic. It reads the session cookie, forwards it, negotiates content type, and hands you back a plain, typed result.

pnpm add @lepresk/next-bff-fetch

Create the client once, from config:

// lib/api.ts
import { createApiFetch } from '@lepresk/next-bff-fetch';

export const apiFetch = createApiFetch({
  apiInternalUrl: process.env.API_INTERNAL_URL ?? 'http://localhost:3001/api/v1',
  sessionCookieName: 'app_session',
});

It imports server-only, so if you ever drag it into a Client Component by accident, the build fails. That's the guarantee you wanted from the start.

Reading Data

// โœ… a Server Action, token never leaves the server
'use server';
import { apiFetch } from '@/lib/api';

export async function getProfile() {
  const res = await apiFetch('/auth/me');
  if (res.status !== 200) return { ok: false as const, status: res.status };
  return { ok: true as const, profile: res.body };
}

res is a plain object: status, a parsed body, the raw setCookieHeaders, and retryAfterSeconds already pulled from Retry-After. No Response juggling, no double await res.json().

Login and Logout

This is the part everyone gets wrong. The API sets the session cookie in its response, and you have to re-emit it on the Next.js origin, or the browser never gets a session.

'use server';
import { apiFetch } from '@/lib/api';
import { applySessionCookieFromApi, clearSessionCookie } from '@lepresk/next-bff-fetch';

const SESSION = 'app_session';

export async function login(email: string, password: string) {
  const res = await apiFetch('/auth/login', {
    method: 'POST',
    body: JSON.stringify({ email, password }),
  });
  if (res.status === 200) {
    await applySessionCookieFromApi(res.setCookieHeaders, {
      sessionCookieName: SESSION,
      maxAgeSeconds: 60 * 60 * 24 * 30,
    });
  }
  return res;
}

export async function logout() {
  await apiFetch('/auth/logout', { method: 'POST' });
  await clearSessionCookie(SESSION);
}

The cookie is HttpOnly and set on your origin. JavaScript never sees it.

When the API Is Down

A reachable server returning a 500 is an answer. A dead server is a thrown TypeError that will crash your render if you let it. The client tells the two apart and degrades instead:

const res = await apiFetch('/orders');
// API unreachable -> res.status === 503, res.body === { code: 'upstream.unavailable', ... }
// You render an "API is unavailable" state instead of a stack trace.

You can override that body, and forward the real client IP and user agent per request with a buildForwardHeaders hook. It stays out of your way until you need it.

What You Actually Get

Draw the one line and the wins are not subtle:

  • The token never enters the browser. HttpOnly stays HttpOnly.
  • CORS is gone, because every request is same-origin.
  • Your API's URL and auth scheme stay private to the server.
  • Login, logout, and refresh work through cookie re-emission, in three function calls instead of a hundred lines.

The library is small, has no runtime magic, ships types, and is MIT licensed. The whole point is that it's boring, so your BFF layer can be too.

pnpm add @lepresk/next-bff-fetch

The browser was never supposed to hold your token. Now it doesn't have to.

Comments

No comments yet. Start the discussion.