Building A Reusable Generic Http Client In Nextjs 14
Overview
When working on web applications, communicating with APIs is essential. Managing API calls efficiently can become challenging as the application scales. A reusable HTTP client allows us to simplify this process by providing a consistent and type-safe way of making requests.
In this article, we’ll walk through building a robust HttpClient
class in Next.js 14 using TypeScript. This client will support GET, POST, PUT, and DELETE requests, handle errors gracefully, and allow generic typing for strong type-checking. We'll also ensure that the implementation is flexible enough for various API endpoints.
Why Build an HTTP Client?
You may wonder why we need to create a custom HTTP client instead of using the built-in fetch
API or an external library like axios
. While fetch
is excellent for basic requests, a custom HTTP client offers several advantages:
- Consistency: Centralizing request logic reduces code duplication and enforces a consistent API interaction pattern.
- Error Handling: You can handle errors globally and gracefully across all requests.
- Type Safety: Generics enable precise typing for API responses, reducing runtime bugs.
- Reusability: A custom HTTP client is easily reusable across the entire project, simplifying API consumption.
Setting Up the Project
First, let’s set up a Next.js project. If you already have one, feel free to skip this step.
-
Create a Next.js 14 project:
npx create-next-app@14 my-next-app cd my-next-app
-
Install TypeScript if it’s not already set up:
npm install --save-dev typescript @types/react @types/node
Building the HttpClient Class
Let’s start by creating a reusable HttpClient
class. This class will encapsulate the logic for making GET
, POST
, PUT
, and DELETE
requests. We'll handle errors, support custom options, and add generic typing to ensure type safety.
1. Creating the Class
In your Next.js project, create a file called HttpClient.ts
in the lib
folder (or any directory you prefer).
// lib/HttpClient.ts
export class HttpClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// Helper method to make fetch requests with a generic response type
protected async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ data: T | null; error: string | null }> {
const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, {
...options,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return {
data: null,
error: `HTTP error! status: ${response.status}`,
};
}
const data: T = await response.json();
return { data, error: null };
} catch (error: any) {
console.error('Request failed:', error);
if (error.name === 'TimeoutError') {
return { data: null, error: 'Request timed out - no response within 5 seconds.' };
} else {
return { data: null, error: error.message || 'Request failed due to an unknown error' };
}
}
}
// GET request with a generic response type
async get<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ data: T | null; error: string | null }> {
return this.request<T>(endpoint, { method: 'GET', ...options });
}
// POST request with a generic response type
async post<T>(
endpoint: string,
body: any,
options: RequestInit = {}
): Promise<{ data: T | null; error: string | null }> {
return this.request<T>(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: JSON.stringify(body),
...options,
});
}
// PUT request with a generic response type
async put<T>(
endpoint: string,
body: any,
options: RequestInit = {}
): Promise<{ data: T | null; error: string | null }> {
return this.request<T>(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: JSON.stringify(body),
...options,
});
}
// DELETE request with a generic response type
async delete<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ data: T | null; error: string | null }> {
return this.request<T>(endpoint, { method: 'DELETE', ...options });
}
}
2. How Does It Work?
- Constructor: The
HttpClient
is initialized with abaseUrl
, which acts as the base for all subsequent API requests. - request
<T>
method: A private method that performs the actualfetch
request. The method is generic (<T>
), meaning you can define the expected response type when calling it. - GET, POST, PUT, DELETE: These methods wrap the
request
method and pass the appropriate HTTP method and options.
3. Handling Errors and Responses
Notice that the request<T>
method always returns an object containing two properties:
data
: If the request is successful, this will hold the response data.error
: If an error occurs (either a network failure or a non-200 response), the error is returned here.
By returning this object, we avoid the need for try-catch blocks in the consuming code and provide a consistent structure for handling responses.
Example Usage
Let’s explore how to use the HttpClient
in different scenarios.
1. Making a GET Request
Suppose you’re fetching a list of blog posts from an API. Here’s how you would do it using our new HttpClient
:
// lib/apiClient.ts
import { HttpClient } from './HttpClient';
// Initialize the client with a base URL
const apiClient = new HttpClient('https://jsonplaceholder.typicode.com');
export async function fetchPosts() {
const { data, error } = await apiClient.get<Post[]>('/posts');
if (error) {
console.error('Error fetching posts:', error);
return null;
}
return data;
}
Here, Post[]
is a generic type indicating the response will be an array of posts.
2. Making a POST Request
To create a new post:
interface Post {
id: number;
title: string;
body: string;
}
export async function createPost(postData: Omit<Post, 'id'>) {
const { data, error } = await apiClient.post<Post>('/posts', postData);
if (error) {
console.error('Error creating post:', error);
return null;
}
return data;
}
We use Omit<Post, 'id'>
to represent the body of the POST request, since the id
will be generated by the server.
3. Adding Cache Control
The fetch
API allows us to add cache-control behavior to requests. Here’s how you can specify cache options:
export async function fetchPostsWithCache() {
const { data, error } = await apiClient.get<Post[]>('/posts', {
cache: 'force-cache',
});
if (error) {
console.error('Error fetching cached posts:', error);
return null;
}
return data;
}
Benefits of Using Generics
With TypeScript generics, we ensure that the data returned from the API is strongly typed. This provides several key benefits:
- Type Safety: If the API response doesn’t match the expected type, TypeScript will alert you at compile time.
- Better Developer Experience: Autocompletion in IDEs ensures you know exactly what to expect when working with API responses.
- Reusability: By making the response type generic, you can use the same
HttpClient
class for different endpoints that return different types of data.
Conclusion
Building a reusable and type-safe HTTP client using TypeScript in Next.js is a powerful way to handle API requests in your project. With features like generic typing and centralized error handling, your code becomes cleaner, more maintainable, and easier to extend. You can now effortlessly fetch data from APIs while ensuring that the responses conform to your expected types.
Give this approach a try in your next project, and enjoy the benefits of a clean and efficient API handling system!
References
Related Articles
Recommended tsconfig settings For Nextjs 14
Recommended tsconfig settings for Nextjs 14
03/10/2024
How to Generate a Table of Contents from Markdown in React Using TypeScript
Learn how to generate a Table of Contents (TOC) from Markdown content in a React application
26/08/2024
Filtering Object Attributes Based on User Roles in TypeScript
Filtering object attributes based on user roles dynamically using TypeScript and CASL to control access to data.
02/10/2024
How to Use CASL in Next.js for Role Based Access Control
How to use CASL in Next.js for role based access control
01/10/2024