An interceptor is a function every outgoing request passes through. It is the one place that knows where the API lives, how the token is attached, and how a failure becomes a typed error.
export const apiInterceptor: HttpInterceptorFn = (req, next) => {Registered once, at bootstrap:
provideHttpClient(withFetch(), withInterceptors([apiInterceptor])),Why an interceptor and not a wrapper function
The React app hand-writes a request() wrapper around fetch that does
the same three jobs. The difference is reach: a wrapper only helps code that
remembers to call it, and nothing stops a component calling fetch directly and skipping
all three. An interceptor is wired into HttpClient itself, so there is no way to make a
request that bypasses it.
Requests are immutable
const authorised = req.clone({
url: `${environment.apiBaseUrl}${req.url}`,
setHeaders: token ? { Authorization: `Bearer ${token}` } : {},
});⚠️ req.url = … does nothing at all, silently. Every change goes through
clone(), which is why both edits are made in one call.
Only touch your own API
const isOurApi = req.url.startsWith('/api/');
if (!isOurApi) return next(req);This is a security boundary, not tidiness. A request for an asset, or to Stripe, must pass through untouched — sending your bearer token to a third party would be a credential leak. An interceptor sees every request the app makes, including ones to other origins, so the check has to be explicit.
Its own unit test asserts exactly that: with a token stored, a request to
api.stripe.com carries no Authorization header.
Attach the token unconditionally
The token is attached whenever one exists, rather than per-call as the React app does with an
auth: true flag. The behaviour is the same — a guest has no token to send — and it
removes a class of bug where a protected endpoint is called without the flag and 401s.
Guest checkout still works, because POST /api/orders is deliberately open and the
server associates the order with an account only when a valid token happens to be present.
Convert the error once
return next(authorised).pipe(
catchError((error: HttpErrorResponse) =>Every caller downstream — a component, a service, an NgRx effect — then handles exactly one
error type and can read fieldErrors() off it without first unpicking Angular's transport
wrapper.
The conversion also translates the case that produces the worst message. Status 0
means the request never reached the server at all — the API is down, or CORS rejected it — and saying
so beats "Http failure response for …: 0 Unknown Error".
Order matters
Interceptors run in the order given to withInterceptors, wrapping each other like
middleware: the first sees the request first and the response last. An interceptor that adds auth
must run before one that logs the final request; a retry interceptor must sit outside the one that
converts errors, or it will retry on a type it no longer recognises. With one interceptor this does
not come up — with three it is the first thing to check when something behaves oddly.
The class-based form
You will meet @Injectable() classes implementing HttpInterceptor,
registered through the HTTP_INTERCEPTORS multi-provider. It still works. A plain
function needs neither, and can still use inject() if it wants a service.
What is next
The RxJS that survives in a signals-first app, and the one place it is unambiguously the right tool.