@lepresk/next-bff-fetch
v0.1.0
Published
Server-only Backend-for-Frontend fetch client for the Next.js App Router: forwards session cookies to an upstream API, re-emits Set-Cookie on the Next origin, negotiates JSON content-type, and degrades gracefully when the upstream is unreachable.
Maintainers
Readme
@lepresk/next-bff-fetch
A server-only fetch client for the Next.js App Router that implements the Backend-for-Frontend (BFF) pattern: the browser talks only to your Next.js server, and the Next.js server talks to your API.
The browser never holds an API token and never sends a cross-origin request, so
the session cookie stays HttpOnly and you do not need CORS.
What it does
createApiFetch returns an apiFetch(path, init) function that, on every call:
- reads the session cookie from the incoming Next.js request and forwards it to the upstream API as a
Cookieheader; - sets
Content-Type: application/jsononly when appropriate, and never forFormData,Blob, orURLSearchParams; - returns a plain, typed result (
status, parsedbody,setCookieHeaders,retryAfterSeconds) instead of a rawResponse; - returns a controlled
503when the API is unreachable, instead of throwing an unhandled transport error.
Companion helpers re-emit the API's Set-Cookie headers onto the Next.js
origin, which is what makes login, logout, and session refresh work through the
BFF.
The entry point imports server-only,
so importing it from a Client Component fails the build by design.
Install
pnpm add @lepresk/next-bff-fetchnext is a peer dependency (>=15, App Router with async cookies() and headers()).
Quick start
Create the client once, from environment configuration:
// 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',
});Read data from a Server Component or Server Action:
'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 };
}Persist the session on login and clear it on logout:
'use server';
import { apiFetch } from '@/lib/api';
import { applySessionCookieFromApi, clearSessionCookie } from '@lepresk/next-bff-fetch';
const SESSION_COOKIE = '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_COOKIE,
maxAgeSeconds: 60 * 60 * 24 * 30,
});
}
return res;
}
export async function logout() {
await apiFetch('/auth/logout', { method: 'POST' });
await clearSessionCookie(SESSION_COOKIE);
}API
createApiFetch(config): (path, init?) => Promise<ApiFetchResult>
| Config field | Type | Description |
| -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------ |
| apiInternalUrl | string | Base URL of the upstream API. A trailing slash is normalized. |
| sessionCookieName | string | Cookie read from the Next.js request and forwarded as the Cookie header. |
| buildForwardHeaders? | () => Record<string, string> \| Promise<...> | Extra headers to forward on every request (for example the real client IP). |
| upstreamUnavailableBody? | unknown | Body returned with 503 when the API is unreachable. Has a sensible default. |
interface ApiFetchResult {
readonly status: number;
readonly body: unknown; // parsed JSON, { message: string } for non-JSON, or null
readonly rawText: string;
readonly setCookieHeaders: readonly string[];
readonly retryAfterSeconds: number | undefined; // parsed from the Retry-After header
}Requests are always sent with cache: 'no-store'.
Session cookie helpers
applySessionSetCookies(setCookieHeaders, options)re-emits every matching session cookie onto the Next.js origin.applySessionCookieFromApi(setCookieHeaders, options)is a login helper that throws when no session cookie is present.clearSessionCookie(name, path?)deletes the cookie on the Next.js origin.
ApplySessionCookiesOptions: { sessionCookieName, cookiePath?, secureInProduction?, maxAgeSeconds? }.
Pass maxAgeSeconds: null to write a browser-session cookie (no Max-Age).
Low-level utilities
parseSetCookieHeader(header)parses oneSet-Cookieheader into{ name, value, maxAge, path, sameSite, secure, httpOnly }.shouldAttachJsonContentType(body, headers)is the content-type negotiation predicate.isUpstreamNetworkError(error)distinguishes a fetch transport failure from an HTTP error response.
Forwarding the real client IP and user agent
buildForwardHeaders runs per request. Read headers() from next/headers and
return whatever trusted metadata your API expects:
import { createApiFetch } from '@lepresk/next-bff-fetch';
import { headers } from 'next/headers';
export const apiFetch = createApiFetch({
apiInternalUrl: process.env.API_INTERNAL_URL ?? '',
sessionCookieName: 'app_session',
buildForwardHeaders: async () => {
const incoming = await headers();
const forwarded: Record<string, string> = {};
const ip = incoming.get('x-forwarded-for')?.split(',')[0]?.trim();
if (ip) forwarded['x-client-ip'] = ip;
const ua = incoming.get('user-agent');
if (ua) forwarded['x-client-user-agent'] = ua;
return forwarded;
},
});Requirements
- Next.js
>=15(App Router, asynccookies()andheaders()) - Node.js
>=18.18
Development
pnpm install
pnpm typecheck
pnpm lint
pnpm test:coverage
pnpm buildThe same steps run in CI on every push and pull request against main, across
Node 20 and 22. A tag matching v* triggers a signed npm publish with
provenance.
