Enhancing Developer Experience with Fetch: A Clean API Service Pattern
The Fetch API is a powerful, built‑in way to make HTTP requests in the browser. It’s modern, promise‑based, and works seamlessly with JavaScript. However, using raw fetch in a real‑world application often leads to repetitive boilerplate code – setting the same headers over and over, constructing URLs manually, and managing base URLs across hundreds of endpoints. This not only clutters your components but also makes maintenance a nightmare.
In this post, I’ll show you a simple yet effective pattern that wraps fetch into a clean, reusable API service layer. With just two files – a helper module and a service module – you can dramatically improve developer experience, reduce bugs, and make your API calls declarative and consistent.
The Problem with Raw fetch
Imagine you’re building an e‑commerce frontend. You need to fetch categories, brands, slides, and products. Each call requires:
- A base URL (e.g.,
https://api.example.com) - Authentication headers (e.g., a Bearer token)
- The
AcceptandContent-Typeheaders - Query string building for parameters
Without a structured approach, your code might look like this:
// In a React component
const token = import.meta.env.VITE_API_TOKEN;
const baseURL = import.meta.env.VITE_API_BASEURL;
const getCategories = async () => {
const response = await fetch(`${baseURL}/v1/categories`, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
method: 'GET',
});
return response.json();
};
Now multiply this by ten endpoints. Every time you need to change the token or the base URL, you have to hunt down every occurrence. Adding a new endpoint means copy‑pasting the same headers and method configuration. This quickly becomes unmaintainable.
The Solution: Centralised Helpers + Service Layer
By extracting the repetitive parts into helpers and grouping all API calls into a dedicated service, we can eliminate duplication and keep our business logic clean.
Let’s look at the two files that make this possible.
helper.ts – The Foundation
export const API_BASEURL = import.meta.env.VITE_API_BASEURL
export const buildRequest = (method: string, payload?: object) => {
const token = import.meta.env.VITE_API_TOKEN
return {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
method: method,
body: JSON.stringify(payload),
}
}
export const buildUrl = (url: string, params?: any) => {
const urlParams = params !== null ? '?' + new URLSearchParams(params) : ''
return API_BASEURL + url + urlParams
}
API_BASEURLreads the base URL from environment variables – a single source of truth.buildRequestcreates a standardRequestInitobject with the common headers, method, and optionally a JSON‑stringified body. The token is also injected automatically.buildUrlconcatenates the base URL with the endpoint and appends query parameters usingURLSearchParams.
Now, every API call can reuse these helpers. No more repeating the same headers or manually building query strings.
ApiService.ts – The API Service Layer
import { buildRequest, buildUrl } from '@/lib/helper'
async function getCategories() {
const requestOptions = buildRequest('GET')
const url = buildUrl('/v1/categories')
const response = await fetch(url, requestOptions)
return await response.json()
}
async function getBrands() {
const requestOptions = buildRequest('GET')
const url = buildUrl('/v1/brands')
const response = await fetch(url, requestOptions)
return await response.json()
}
async function getSlides() {
const requestOptions = buildRequest('GET')
const url = buildUrl('/v1/slider')
const response = await fetch(url, requestOptions)
return await response.json()
}
async function getProducts() {
const requestOptions = buildRequest('GET')
const url = buildUrl('/v1/search', {
category: '',
brands: '',
searchkey: '',
page: 0,
})
const response = await fetch(url, requestOptions)
return await response.json()
}
export const ApiService = {
getCategories,
getBrands,
getSlides,
getProducts
}
Each function:
- Calls
buildRequestwith the correct HTTP method. - Calls
buildUrlwith the endpoint and any optional query parameters. - Executes
fetchand returns the parsed JSON.
Finally, we export an object ApiService that holds all these functions. This gives us a single, clean API to import anywhere in the app.
Using the Service
Now, consuming an API in a component is as simple as:
import { ApiService } from '@/services/ApiService'
const categories = await ApiService.getCategories()
const products = await ApiService.getProducts({ page: 2 })
The component no longer cares about the base URL, headers, or how the request is built. It just calls a well‑named function and gets back the data.
Benefits for Developer Experience
1. Single Source of Truth
The base URL and authentication token are defined once in helper.ts. Changing them affects every API call instantly.
2. Less Boilerplate
Adding a new endpoint means writing just a few lines – call buildRequest, buildUrl, and fetch. No more copy‑pasting headers.
3. Consistent Error Handling (easily extensible)
You can enhance buildRequest or add a wrapper around fetch to handle errors globally. For example, you could add a try‑catch, log errors, or display toasts – all in one place.
4. Query Parameter Management
buildUrl uses URLSearchParams under the hood, which automatically handles encoding and formatting. No more manual string concatenation.
5. Better Type Safety (if you use TypeScript)
You can define response types for each function and return a typed promise. This gives you autocompletion and catches errors at compile time.
6. Testability
Since the API calls are isolated in a service, you can easily mock ApiService in unit tests without touching fetch directly.
7. Readability
The code reads like a contract: ApiService.getCategories() – you immediately know what it does, without digging into implementation details.
Taking It Further
The pattern above is a great starting point, but you can extend it even more:
- Add request/response interceptors to handle loading states or authentication refresh.
- Include error handling – wrap the
fetchcall in a try‑catch and throw a custom error with status codes. - Support other HTTP methods like
POST,PUT,DELETEby passing a payload tobuildRequest. - Use AbortController to cancel in‑flight requests when components unmount.
Conclusion
The Fetch API is a fantastic tool, but using it raw in a large application often leads to duplication and friction. By centralising the configuration with helpers and encapsulating each endpoint in a dedicated service, you gain a clean, maintainable, and developer‑friendly API layer.
The two files – helper.ts and ApiService.ts – show that with just a little bit of structure, you can turn repetitive fetch calls into a delightful experience. Your components become simpler, your team works faster, and your codebase stays resilient to change.
Give this pattern a try on your next project – your future self (and your teammates) will thank you.