Le pattern BFF : votre token API n'a rien a faire dans le navigateur
Vous avez une app Next.js sur un domaine et votre API sur un autre. Le login fonctionne, le dashboard se remplit, tout a l'air normal. Puis un jour vous ouvrez l'onglet Network et là, c'est votre token d'accès, en clair, dans un header de requête que le navigateur a envoyé tout seul.
À partir de ce moment-là, n'importe quelle ligne de JavaScript présente sur la page peut le lire. Pas seulement votre code : le script d'analytics ajouté la semaine dernière, les douze dépendances transitives que vous n'avez jamais ouvertes, ou tout ce qu'une faille XSS parvient à glisser. Et vous n'avez jamais vraiment décidé de ça. C'est arrivé tout seul, parce qu'appeler l'API directement depuis le composant était le chemin de moindre résistance, et que rien n'a bronché.
Le navigateur n'est pas un client de confiance
La version qui vous mène là a l'air parfaitement raisonnable :
// ❌ un Client Component qui appelle directement votre API
'use client';
export function Profile() {
useEffect(() => {
fetch('https://api.example.com/me', {
headers: { authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(setProfile);
}, []);
}
Regardez ce que ça implique réellement.
Le token est en JavaScript, donc la protection qu'un cookie HttpOnly vous aurait apportée disparaît purement et simplement. L'appel est cross-origin, donc vous héritez de toute la mécanique CORS : requêtes preflight, liste d'origines autorisées à maintenir, Access-Control-Allow-Credentials, et cette inquiétude sourde d'avoir ouvert la porte plus large que nécessaire. Et comme la requête part du navigateur, n'importe qui avec les devtools peut lire l'URL de base de votre API, ses routes, et la façon dont elle attend d'être authentifiée. C'est une carte assez précise de votre backend, distribuée à chaque visiteur.
Ce qui rend le problème difficile à repérer, c'est que rien de tout ça ne casse. Ça marche en démo, ça marche en production, ça passe la revue de code sans accroc. Ça reste simplement là, comme un passif silencieux, jusqu'au jour où ça cesse de l'être.
Le navigateur parle à votre serveur. Votre serveur parle à votre API.
Le pattern Backend-for-Frontend trace une ligne claire : le navigateur ne parle jamais qu'à votre serveur Next.js. Et c'est le serveur Next.js qui parle à votre API.
Browser -> Next.js (Server Actions / Route Handlers) -> API
L'App Router a été conçu pour ça. Les Server Actions et les Route Handlers s'exécutent côté serveur, là où le cookie de session est lisible et où le token n'a jamais besoin de sortir. Le navigateur ne détient qu'un unique cookie de session HttpOnly, et rien d'autre. Il ignore l'URL de votre API. Il ne peut pas lire le token. Et comme tout se passe désormais en same-origin, le CORS disparaît entièrement.
Ce n'est pas une idée nouvelle. C'est la bonne approche. Le problème, c'est ce que ça coûte à implémenter à la main.
Le BFF, c'est là que vit le code fastidieux
Déplacez ce fetch vers le serveur, et le code répétitif apparaît immédiatement. Il faut alors :
- lire le cookie de session de la requête entrante et le transmettre à l'API,
- récupérer le
Set-Cookierenvoyé par l'API au login et le réémettre sur l'origine Next.js, - poser
Content-Type: application/jsonpour les corps JSON, mais jamais pourFormDataou les uploads de fichiers, - parser un
Retry-Aftersur un 429, - et décider de ce qui se passe quand l'API est tout simplement injoignable, plutôt que de laisser fuiter une erreur de transport brute dans le rendu d'une page.
Résultat : chaque projet finit par développer sa propre version de tout ça :
// ❌ les mêmes 120 lignes, réécrites dans chaque projet
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
}
Ce n'est pas difficile. C'est juste tatillon, facile à rater subtilement, et ça n'a aucune raison d'être réécrit dans chaque dépôt. Alors je l'ai extrait.
@lepresk/next-bff-fetch
Un client fetch server-only pour l'App Router qui prend en charge exactement cette plomberie. Zéro magie. Il lit le cookie de session, le transmet, négocie le content type, et vous renvoie un résultat typé, tout simple.
pnpm add @lepresk/next-bff-fetch
Créez le client une fois, à partir de la 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',
});
Il importe server-only, donc si vous l'importez par erreur dans un Client Component, le build échoue. C'est exactement la garantie que vous vouliez dès le départ.
Lire des données
// ✅ une Server Action, le token ne quitte jamais le serveur
'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 est un objet simple : status, un body déjà parsé, les setCookieHeaders bruts, et retryAfterSeconds déjà extrait du Retry-After. Plus de jonglage avec un objet Response, plus de double await res.json().
Login et logout
C'est le point que tout le monde rate. L'API pose le cookie de session dans sa réponse, et vous devez le réémettre sur l'origine Next.js, sinon le navigateur n'obtient jamais de 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);
}
Le cookie est HttpOnly et posé sur votre origine. JavaScript ne le voit jamais.
Quand l'API est indisponible
Un serveur joignable qui renvoie un 500, c'est une réponse. Un serveur mort, c'est un TypeError levé qui plantera votre rendu si vous le laissez faire. Le client distingue les deux cas et dégrade proprement au lieu de planter :
const res = await apiFetch('/orders');
// API injoignable -> res.status === 503, res.body === { code: 'upstream.unavailable', ... }
// Vous affichez un état "API indisponible" plutôt qu'une stack trace.
Vous pouvez personnaliser ce corps de réponse, et transmettre l'IP réelle du client ainsi que le user agent par requête via un hook buildForwardHeaders. Il reste discret tant que vous n'en avez pas besoin.
Ce que vous y gagnez réellement
Tracez cette unique ligne de séparation, et les bénéfices ne sont pas anodins :
- Le token n'entre jamais dans le navigateur.
HttpOnlyresteHttpOnly. - Le CORS disparaît, puisque toutes les requêtes sont same-origin.
- L'URL de votre API et son schéma d'authentification restent privés, connus du seul serveur.
- Login, logout et refresh fonctionnent grâce à la réémission de cookie, en trois appels de fonction au lieu de cent lignes.
La librairie est légère, ne contient aucune magie à l'exécution, fournit des types, et est publiée sous licence MIT. Tout l'intérêt, c'est qu'elle soit ennuyeuse, pour que votre couche BFF puisse l'être aussi.
pnpm add @lepresk/next-bff-fetch
- npm : https://www.npmjs.com/package/@lepresk/next-bff-fetch
- GitHub : https://github.com/lepresk/next-bff-fetch
Le navigateur n'était jamais censé porter votre token. Maintenant, il n'a plus à le faire.