Company Logo

Bisht Bytes

Building A Reusable Generic Http Client In Nextjs 14

Published On: 04 Oct 2024
Reading Time: 10 minutes

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.

  1. Create a Next.js 14 project:

    npx create-next-app@14 my-next-app
    cd my-next-app
    
  2. 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 a baseUrl, which acts as the base for all subsequent API requests.
  • request<T> method: A private method that performs the actual fetch 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:

  1. Type Safety: If the API response doesn’t match the expected type, TypeScript will alert you at compile time.
  2. Better Developer Experience: Autocompletion in IDEs ensures you know exactly what to expect when working with API responses.
  3. 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


Page Views: -