π API Integration Patterns
Once CORS lets your frontend reach your API, the next question is how to organize the calls. Scatter fetch across a hundred components and every URL change becomes a hunt-and-replace; centralize it well and adding a feature is a one-liner. This lesson walks the three patterns you'll actually use β direct calls, a reusable client wrapper, and Backend-for-Frontend β and when each one earns its keep.
Week 10 · Monday: Connecting Frontend to Backend · Lecture 2
π― Learning Objectives
By the end of this lesson, you will be able to:
- Make a direct
fetchcall with proper loading, error, and success states - Explain why raw
fetchscattered through components does not scale - Build a reusable API client wrapper with a base URL, auth injection, and centralized error handling
- Use request/response interceptors to attach tokens and catch 401s in one place
- Describe the Backend-for-Frontend (BFF) pattern and when it beats direct calls
- Choose the right pattern for a given app's size, team, and security needs
Estimated Time: 65 minutes
Practice: Refactor a component that calls fetch directly into a service backed by a shared API client.
In This Lesson
The Request Lifecycle
Every API call, no matter how you structure it, passes through the same lifecycle: the UI kicks off a request, enters a pending state, and eventually resolves to either data or an error. The reason patterns matter is that this lifecycle repeats everywhere β every list, every form, every detail page. Where you put the repeated bits is the whole game.
Think of it like a restaurant. The waitstaff (your frontend) takes an order and passes it to the kitchen (your API). A tiny cafΓ© can have the waiter walk straight into the kitchen (direct integration). A busy restaurant adds an order system that standardizes how tickets are written and routed (a client wrapper). A chain with multiple dining rooms adds a head server per room who bundles and tailors orders for that room's needs (Backend-for-Frontend).
Why it matters: a well-chosen pattern shapes your app's performance, maintainability, and security all at once. Get it right early and the codebase stays calm as it grows; get it wrong and every API tweak ripples through dozens of files.
Pattern 1: Direct Integration
The simplest pattern: a component calls fetch itself. This is where everyone starts, and for a small app it is perfectly fine. The one thing you must never skip is the full lifecycle β loading, error, and success β because a UI that only handles the happy path looks broken the moment the network hiccups.
A React component doing it right
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// AbortController cancels the request if the component unmounts
// (or userId changes) before it finishes β no state-update warnings.
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
// fetch only rejects on network failure β you MUST check res.ok.
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
setUser(await res.json());
} catch (err) {
if (err.name !== 'AbortError') setError(err.message);
} finally {
setLoading(false);
}
}
load();
return () => controller.abort();
}, [userId]);
if (loading) return <p>Loadingβ¦</p>;
if (error) return <p role="alert">Error: {error}</p>;
if (!user) return <p>No user found.</p>;
return (
<section>
<h2>{user.name}</h2>
<p>{user.email}</p>
</section>
);
}
β οΈ The fetch gotcha that bites everyone
fetch does not throw on a 404 or 500 β it only rejects on a network-level failure. A "successful" response to a missing resource still has res.ok === false. Always check res.ok (or use axios, which throws on 4xx/5xx by default) or your error state will never fire.
The catch with direct integration: the URL, the res.ok check, the auth header, and the error shape are duplicated in every component. Change the base URL and you edit fifty files. That pain is exactly what the next pattern removes.
Pattern 2: API Client Wrapper
An API client wrapper is a single module that owns all HTTP concerns β base URL, headers, auth, error normalization β and exposes clean methods to the rest of the app. Components stop knowing about URLs and status codes; they just call userService.getById(id). GitHub's Octokit is a famous production example of this idea.
The base client
// api/client.js β one axios instance for the whole app
import axios from 'axios';
const client = axios.create({
// Base URL comes from an env var (Vite exposes import.meta.env).
// In dev this is '/api' so the Vite proxy handles it; in prod it is
// the real API origin. Never hardcode it.
baseURL: import.meta.env.VITE_API_URL || '/api',
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true // send cookies for the allowlisted origin
});
export default client;
Resource-specific services
On top of the client, expose a small service per resource. This is the surface your components touch:
// api/userService.js
import client from './client';
export const userService = {
getAll: (params) => client.get('/users', { params }).then(r => r.data),
getById: (id) => client.get(`/users/${id}`).then(r => r.data),
create: (data) => client.post('/users', data).then(r => r.data),
update: (id, data) => client.put(`/users/${id}`, data).then(r => r.data),
remove: (id) => client.delete(`/users/${id}`).then(r => r.data)
};
// The same component as before β now blissfully unaware of URLs.
import { userService } from '../api/userService';
async function load() {
setLoading(true);
setError(null);
try {
setUser(await userService.getById(userId));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
β What you just bought
The base URL, timeout, credentials, and content type are defined once. A backend move is a one-line env change. Every component gets consistent behavior for free, and services are trivial to mock in tests. This is the default for anything past a weekend prototype.
Interceptors: Auth & Errors
The real payoff of a wrapper is interceptors β hooks that run on every request or response. They let you attach the auth token in one place and handle an expired session in one place, instead of repeating that logic in every call.
// api/client.js (continued)
import axios from 'axios';
const client = axios.create({ baseURL: import.meta.env.VITE_API_URL || '/api' });
// REQUEST interceptor: attach the token to every outgoing call.
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// RESPONSE interceptor: normalize errors and handle 401 once.
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Session expired β clear it and bounce to login, everywhere.
localStorage.removeItem('token');
window.location.assign('/login');
}
// Give the whole app one predictable error shape.
const message = error.response?.data?.message || 'Something went wrong';
return Promise.reject(new Error(message));
}
);
export default client;
π‘ One place to change, everywhere to benefit
Switch from localStorage tokens to httpOnly cookies? Edit the request interceptor. Add a global "your session expired" toast? Edit the response interceptor. Neither change touches a single component. That leverage is why the wrapper pattern pays for its upfront cost almost immediately.
β οΈ Keep secrets off the client
Anything the browser sends β including tokens in localStorage β is visible to the user and to any script on the page. Never put a private API key or database secret in frontend code or a VITE_ variable. Secrets that must stay secret belong on the server, which brings us to the next pattern.
Pattern 3: Backend for Frontend
The Backend-for-Frontend (BFF) pattern puts a thin server between your frontend and everything else. The browser talks only to the BFF, which shares its origin (goodbye CORS) and which calls internal services server-side, where secrets are safe. It also lets you fold several backend calls into one tailored response β perfect for a dashboard.
A BFF endpoint that aggregates
// server.js β an Express BFF for the web dashboard
const express = require('express');
const app = express();
// A private token lives ONLY on the server, never in the browser.
const SERVICE_TOKEN = process.env.SERVICE_TOKEN;
async function callService(url) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${SERVICE_TOKEN}` }
});
if (!res.ok) throw new Error(`Upstream failed: ${res.status}`);
return res.json();
}
app.get('/api/dashboard/:userId', async (req, res) => {
const { userId } = req.params;
try {
// Fan out in parallel, then combine into ONE frontend-shaped payload.
const [user, orders, picks] = await Promise.all([
callService(`${process.env.USER_SERVICE}/users/${userId}`),
callService(`${process.env.ORDER_SERVICE}/users/${userId}/recent`),
callService(`${process.env.PRODUCT_SERVICE}/recommendations/${userId}`)
]);
res.json({
name: user.name,
recentOrders: orders.map(o => ({ id: o.id, total: o.total })),
recommendations: picks.slice(0, 3)
});
} catch (err) {
res.status(502).json({ message: 'Failed to load dashboard' });
}
});
app.listen(3001, () => console.log('BFF on http://localhost:3001'));
// The frontend makes ONE call instead of three β less latency,
// less round-trip logic, and no secrets in the browser.
const dashboard = await userService.client
.get(`/dashboard/${userId}`)
.then(r => r.data);
Netflix uses BFFs heavily: each device class (TV, mobile, web) gets a BFF that tailors payloads to that screen's needs while a single set of core services stays unchanged. Why it matters: the BFF is where you consolidate round-trips, hide backend complexity, and keep credentials server-side β the three things a pure client-side approach cannot do.
π‘ BFF and CORS are cousins
Because the BFF shares the frontend's origin, browser-to-BFF calls are same-origin β no CORS at all. The only cross-origin hops are BFF-to-service, which happen server-side where CORS does not apply. It is the heavyweight sibling of the dev proxy you met last lesson.
Choosing a Pattern
There is no single "best" β the right choice tracks your app's size and constraints. Use this as a decision guide, and remember real apps often mix patterns (direct calls for simple reads, a BFF for the dashboard).
| Pattern | Best for | Main trade-off |
|---|---|---|
| Direct integration | Prototypes, tiny apps, one dev | API details leak into every component |
| API client wrapper | Most real apps; several endpoints | A little setup up front |
| Backend for Frontend | Multiple clients, microservices, secrets to hide | Another server to deploy and run |
Practice & Quiz
ποΈ Exercise 1: Refactor to a service
Goal: A component calls fetch('/api/products') directly. Extract a productService backed by a shared axios client so the component calls productService.getAll() instead.
// Before (inside the component):
const res = await fetch('/api/products');
if (!res.ok) throw new Error('Failed');
const products = await res.json();
// TODO: create api/client.js and api/productService.js,
// then replace the block above with one line.
π‘ Hint
Create one axios.create({ baseURL }) instance, then a service object whose getAll returns client.get('/products').then(r => r.data).
β Solution
// api/client.js
import axios from 'axios';
export default axios.create({ baseURL: import.meta.env.VITE_API_URL || '/api' });
// api/productService.js
import client from './client';
export const productService = {
getAll: () => client.get('/products').then(r => r.data)
};
// In the component:
const products = await productService.getAll();
ποΈ Exercise 2: Spot the missing check
Goal: This code never shows an error even when the API returns 500. Why, and what one line fixes it?
const res = await fetch('/api/orders');
const orders = await res.json(); // runs even on a 500
setOrders(orders);
β Solution
fetch does not reject on HTTP error statuses, so the 500 body is parsed as if it were data. Add the res.ok guard:
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
π― Quick Quiz
Question 1: Why does raw fetch spread across components not scale?
Question 2: What is a request interceptor good for?
Question 3: Which pattern keeps a private service token out of the browser?
Best Practices & Pitfalls
β Do
- Centralize HTTP concerns in one client wrapper plus per-resource services
- Always render loading and error states, not just the happy path
- Check
res.okwithfetch, or use axios which throws on 4xx/5xx - Attach auth and handle 401s in interceptors, not in every call
- Reach for a BFF when you must hide secrets or merge many calls
β Don't
- Hardcode base URLs β read them from an env var so environments differ cleanly
- Put private keys or secrets in frontend code or
VITE_variables - Assume
fetchrejects on 404/500 β it does not - Reach for a BFF or GraphQL on day one of a two-page app; start simple
π Where GraphQL fits
GraphQL is a fourth option: one endpoint where the client asks for exactly the fields it needs. It shines when different screens need different slices of deeply related data. It is powerful but adds real complexity β treat it as a tool you graduate into, not a default. REST plus a good client wrapper carries most apps a very long way.
Summary
π Key Takeaways
- Every API call shares one lifecycle: loading β data or error β done
- Direct integration is fine for prototypes but leaks API details everywhere
- An API client wrapper centralizes base URL, auth, and error handling
- Interceptors attach tokens and catch 401s in exactly one place
- A BFF hides secrets server-side and merges many calls into one
- Match the pattern to the app; mixing patterns is normal and healthy
π Additional Resources
- MDN β Using the Fetch API
- Axios β Interceptors
- Express β Routing guide
- Sam Newman β Backend For Frontend pattern
π What's Next?
You keep reading "from an env var" β VITE_API_URL, CORS_ORIGINS, SERVICE_TOKEN. The next lesson makes that rigorous: Environment Variables β dotenv on the server, import.meta.env on the client, and how to keep secrets out of the bundle for good.
π Wired up cleanly!
Your frontend and backend now talk through a structure that will not fight you as the app grows.