Picture this: You're building your dream Next.js application, and you're happily making API calls with the native fetch
function. Everything seems great until... your app goes to production. Suddenly, you're drowning in repetitive error handling code, wrestling with authentication tokens scattered across components, and pulling your hair out trying to debug why some requests work on the server but fail on the client.
Sound familiar? You're not alone. While Next.js extends fetch
with powerful caching and revalidation features, the core challenge remains: building a robust, production-ready API layer requires much more than raw fetch
calls.
Today, we're going to craft a production-ready fetch wrapper that will transform your API layer from a source of headaches into a joy to work with. We'll explore not just how to build it, but why each decision matters and how it fits into Next.js's unique execution model.
The Problem with Raw Fetch (Even in Next.js)
Let's start by looking at what most developers write when they first encounter fetch
:
// The naive approach - don't do this in production!
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user;
}
This looks innocent enough, but it's a ticking time bomb. Here's what happens when the API returns a 404:
// This will throw an error when trying to parse JSON from an error response
const user = await getUser('nonexistent-id'); // 💥 Boom!
The fundamental issue is that fetch
doesn't automatically handle HTTP error statuses. A 404, 500, or any other error status is considered a "successful" request by fetch
—it only rejects on network errors. You still need to manually check response.ok
and handle errors appropriately, even in Next.js.
But that's just the beginning. In a real application, you'll also need to handle:
- Authentication tokens for protected routes
- Consistent error handling across your app
- Request/response transformation (like automatic JSON parsing)
- Loading states and error boundaries
- TypeScript support for type safety
- Different behavior for server vs. client contexts in Next.js
Let's build a wrapper that elegantly solves all these problems.
Designing Our Fetch Wrapper Architecture
Before we dive into code, let's think about what our ideal wrapper should provide:
- Automatic error handling for HTTP status codes
- Built-in JSON parsing with proper error handling
- Authentication token management
- TypeScript support with proper typing
- Next.js context awareness (server vs. client)
- Extensible configuration for different environments
- Consistent API that feels natural to use
Think of our wrapper as a friendly assistant that handles all the tedious, error-prone tasks while giving you a clean, predictable interface to work with.
Building the Foundation: Core Wrapper Structure
Let's start with the basic structure. We'll create a file called lib/api.ts
(following Next.js conventions):
// lib/api.ts
interface ApiConfig {
baseUrl?: string;
defaultHeaders?: Record<string, string>;
timeout?: number;
}
interface ApiResponse<T = any> {
data: T;
status: number;
headers: Headers;
}
class ApiError extends Error {
constructor(
message: string,
public status: number,
public response?: Response
) {
super(message);
this.name = 'ApiError';
}
}
class ApiClient {
private config: Required<ApiConfig>;
constructor(config: ApiConfig = {}) {
this.config = {
baseUrl: config.baseUrl || '',
defaultHeaders: {
'Content-Type': 'application/json',
...config.defaultHeaders,
},
timeout: config.timeout || 10000,
};
}
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
// We'll implement this next
}
}
Why a class-based approach? While functional approaches are popular in React, a class gives us several advantages:
- State management: We can store configuration and potentially cache data
- Method chaining: We can add fluent API methods later
- Inheritance: Teams can extend the base class for specific use cases
- Encapsulation: Private methods keep the implementation details hidden
The Heart of the Wrapper: Request Logic
Now let's implement the core request logic. This is where the magic happens:
// lib/api.ts (continued)
class ApiClient {
// ... previous code
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const url = this.buildUrl(endpoint);
const requestOptions = this.buildRequestOptions(options);
try {
// Create AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const response = await fetch(url, {
...requestOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Here's the key: we check the status and throw for errors
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response
);
}
// Parse JSON safely
const data = await this.parseResponse<T>(response);
return {
data,
status: response.status,
headers: response.headers,
};
} catch (error) {
if (error.name === 'AbortError') {
throw new ApiError('Request timeout', 408);
}
throw error;
}
}
private buildUrl(endpoint: string): string {
// Handle both absolute and relative URLs
if (endpoint.startsWith('http')) {
return endpoint;
}
return `${this.config.baseUrl}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
}
private buildRequestOptions(options: RequestInit): RequestInit {
return {
...options,
headers: {
...this.config.defaultHeaders,
...options.headers,
},
};
}
private async parseResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
try {
return await response.json();
} catch (error) {
throw new ApiError('Invalid JSON response', response.status, response);
}
}
// Handle text responses
return (await response.text()) as unknown as T;
}
}
The crucial insight here is that we're transforming fetch
's behavior to be more intuitive. By checking response.ok
and throwing an error for HTTP errors, we make error handling predictable and consistent throughout our application.
Adding HTTP Method Convenience Methods
Now let's add the methods that developers will actually use:
// lib/api.ts (continued)
class ApiClient {
// ... previous code
async get<T>(endpoint: string, options?: RequestInit): Promise<ApiResponse<T>> {
return this.makeRequest<T>(endpoint, { ...options, method: 'GET' });
}
async post<T>(
endpoint: string,
data?: any,
options?: RequestInit
): Promise<ApiResponse<T>> {
return this.makeRequest<T>(endpoint, {
...options,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(
endpoint: string,
data?: any,
options?: RequestInit
): Promise<ApiResponse<T>> {
return this.makeRequest<T>(endpoint, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string, options?: RequestInit): Promise<ApiResponse<T>> {
return this.makeRequest<T>(endpoint, { ...options, method: 'DELETE' });
}
async patch<T>(
endpoint: string,
data?: any,
options?: RequestInit
): Promise<ApiResponse<T>> {
return this.makeRequest<T>(endpoint, {
...options,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
}
}
Notice how we're automatically JSON.stringify-ing the data for POST, PUT, and PATCH requests. This eliminates another common source of bugs—forgetting to stringify your request body.
Authentication: The Token Management Challenge
Authentication is where things get interesting in Next.js. Unlike traditional SPAs, we have to consider both server-side and client-side contexts. Let's add authentication support:
// lib/api.ts (continued)
interface AuthConfig {
tokenProvider?: () => Promise<string | null> | string | null;
tokenHeader?: string;
tokenPrefix?: string;
}
class ApiClient {
private authConfig: AuthConfig;
constructor(config: ApiConfig = {}, authConfig: AuthConfig = {}) {
// ... previous constructor code
this.authConfig = {
tokenHeader: 'Authorization',
tokenPrefix: 'Bearer',
...authConfig,
};
}
private async buildRequestOptions(options: RequestInit): Promise<RequestInit> {
const headers = { ...this.config.defaultHeaders };
// Add authentication token if available
if (this.authConfig.tokenProvider) {
const token = await this.authConfig.tokenProvider();
if (token) {
headers[this.authConfig.tokenHeader!] =
`${this.authConfig.tokenPrefix} ${token}`;
}
}
return {
...options,
headers: {
...headers,
...options.headers,
},
};
}
// Update makeRequest to use async buildRequestOptions
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const url = this.buildUrl(endpoint);
const requestOptions = await this.buildRequestOptions(options);
// ... rest of the method stays the same
}
}
The token provider pattern is crucial here. Instead of directly storing tokens, we provide a function that can retrieve them. This allows us to:
- Fetch fresh tokens from different sources (localStorage, cookies, memory)
- Handle token refresh logic transparently
- Work in both server and client contexts with different storage mechanisms
Creating Context-Aware API Instances
Now comes the Next.js-specific magic. We need different configurations for different execution contexts:
// lib/api.ts (continued)
// Client-side token provider (browser only)
const getClientToken = (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('auth_token');
};
// Server-side token provider (for SSR)
const getServerToken = (): string | null => {
// In server components, you might get tokens from cookies
// This is a simplified example - in practice, you'd use Next.js headers()
return null;
};
// Create different instances for different contexts
export const clientApi = new ApiClient(
{
baseUrl: process.env.NEXT_PUBLIC_API_URL || '/api',
},
{
tokenProvider: getClientToken,
}
);
export const serverApi = new ApiClient(
{
baseUrl: process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api',
},
{
tokenProvider: getServerToken,
}
);
// For convenience, export a context-aware API
export const api = typeof window === 'undefined' ? serverApi : clientApi;
This is a game-changer. By creating context-aware instances, we can use the same API interface everywhere while having different behavior on server vs. client. The server instance might use absolute URLs and server-side authentication, while the client instance uses relative URLs and browser storage.
TypeScript: Making It Type-Safe
Let's add proper TypeScript support to make our wrapper even more robust:
// lib/api.ts - Adding TypeScript interfaces
interface User {
id: string;
name: string;
email: string;
}
interface ApiEndpoints {
// Define your API shape
'/users': {
GET: { data: User[] };
POST: { body: Omit<User, 'id'>; data: User };
};
'/users/:id': {
GET: { data: User };
PUT: { body: Partial<User>; data: User };
DELETE: { data: { success: boolean } };
};
}
// Type-safe wrapper methods
class TypedApiClient extends ApiClient {
async getTyped<T extends keyof ApiEndpoints, M extends keyof ApiEndpoints[T]>(
endpoint: T,
method: M extends 'GET' ? 'GET' : never
): Promise<ApiEndpoints[T][M] extends { data: infer D } ? D : never> {
const response = await this.get(endpoint as string);
return response.data;
}
// Similar methods for POST, PUT, DELETE...
}
While this adds complexity, it provides incredible developer experience. Your IDE will autocomplete endpoints, catch typos, and ensure you're passing the right payload shapes.
Usage in Next.js Components
Now let's see how our wrapper shines in real Next.js scenarios:
Server Component Usage
// app/users/page.tsx - Server Component
import { serverApi } from '@/lib/api';
interface User {
id: string;
name: string;
email: string;
}
export default async function UsersPage() {
try {
const { data: users } = await serverApi.get<User[]>('/users');
return (
<div>
<h1>Users</h1>
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
} catch (error) {
if (error instanceof ApiError) {
return <div>Error: {error.message}</div>;
}
return <div>Something went wrong</div>;
}
}
Client Component with React Hook
// components/UserProfile.tsx - Client Component
'use client';
import { useState, useEffect } from 'react';
import { clientApi, ApiError } from '@/lib/api';
interface User {
id: string;
name: string;
email: string;
}
interface UserProfileProps {
userId: string;
}
export function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchUser() {
try {
setLoading(true);
const { data } = await clientApi.get<User>(`/users/${userId}`);
setUser(data);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError('An unexpected error occurred');
}
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
Route Handler Usage
// app/api/users/route.ts - API Route Handler
import { NextRequest } from 'next/server';
import { serverApi } from '@/lib/api';
export async function GET(request: NextRequest) {
try {
// Forward request to external API
const { data } = await serverApi.get('/external-api/users');
return Response.json(data);
} catch (error) {
if (error instanceof ApiError) {
return Response.json(
{ error: error.message },
{ status: error.status }
);
}
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Advanced Features: Caching and Request Deduplication
For production applications, you might want to add caching and request deduplication:
// lib/api.ts - Advanced features
class ApiClient {
private cache = new Map<string, { data: any; timestamp: number }>();
private pendingRequests = new Map<string, Promise<any>>();
async get<T>(
endpoint: string,
options?: RequestInit & { cache?: boolean; cacheTTL?: number }
): Promise<ApiResponse<T>> {
const cacheKey = `GET:${endpoint}`;
const now = Date.now();
// Check cache first
if (options?.cache) {
const cached = this.cache.get(cacheKey);
const ttl = options.cacheTTL || 60000; // 1 minute default
if (cached && (now - cached.timestamp) < ttl) {
return { data: cached.data, status: 200, headers: new Headers() };
}
}
// Deduplicate concurrent requests
if (this.pendingRequests.has(cacheKey)) {
return this.pendingRequests.get(cacheKey)!;
}
const requestPromise = this.makeRequest<T>(endpoint, { ...options, method: 'GET' });
this.pendingRequests.set(cacheKey, requestPromise);
try {
const response = await requestPromise;
// Cache successful responses
if (options?.cache && response.status === 200) {
this.cache.set(cacheKey, { data: response.data, timestamp: now });
}
return response;
} finally {
this.pendingRequests.delete(cacheKey);
}
}
}
Error Handling Strategies
One of the biggest advantages of our wrapper is centralized error handling. Here's how to leverage it:
// lib/error-handler.ts
import { ApiError } from './api';
export function handleApiError(error: unknown): string {
if (error instanceof ApiError) {
switch (error.status) {
case 401:
// Redirect to login or refresh token
return 'Please log in to continue';
case 403:
return 'You don\'t have permission to perform this action';
case 404:
return 'The requested resource was not found';
case 500:
return 'Server error. Please try again later';
default:
return error.message;
}
}
return 'An unexpected error occurred';
}
// Usage in components
import { handleApiError } from '@/lib/error-handler';
try {
await api.get('/protected-resource');
} catch (error) {
const errorMessage = handleApiError(error);
toast.error(errorMessage);
}
File Structure and Organization
Here's how I recommend organizing your API layer:
lib/
├── api/
│ ├── index.ts # Main API client and exports
│ ├── types.ts # TypeScript interfaces
│ ├── endpoints.ts # Endpoint definitions
│ └── error-handler.ts # Error handling utilities
├── hooks/
│ ├── useApi.ts # Custom React hooks
│ └── useAuth.ts # Authentication hooks
└── utils/
└── api-helpers.ts # Utility functions
This structure keeps your API layer organized and makes it easy for team members to find what they need.
Best Practices and Common Pitfalls
Do's:
- Always handle errors explicitly - don't let them bubble up uncaught
- Use TypeScript interfaces for request/response shapes
- Create separate instances for different environments or services
- Implement proper loading states in your components
- Cache responses when appropriate to reduce network requests
Don'ts:
- Don't store sensitive tokens in localStorage for production apps (use httpOnly cookies)
- Don't ignore HTTP status codes - handle different statuses appropriately
- Don't make API calls in render methods - use useEffect or server components
- Don't forget about request timeouts - they prevent hanging requests
- Don't hardcode URLs - use environment variables
Common Pitfalls:
The "works on my machine" trap: Always test in different Next.js contexts (server, client, API routes)
Token refresh hell: Implement proper token refresh logic before you need it
Error boundary neglect: Wrap your API-consuming components in error boundaries
The Bigger Picture: Why This Matters
Building a robust fetch wrapper isn't just about cleaner code—it's about creating a reliable foundation for your application. When your API layer is solid, you can:
- Focus on features instead of debugging network issues
- Scale confidently knowing your request handling is consistent
- Onboard new developers faster with a clear, documented API interface
- Maintain better code quality through centralized error handling and typing
Think of it as investing in your future self. The hour you spend building this wrapper will save you dozens of hours debugging production issues, writing repetitive error handling code, and hunting down network-related bugs.
Conclusion: Your API Layer as a Competitive Advantage
We've journeyed from the humble beginnings of raw fetch
calls to a sophisticated, production-ready API client that handles authentication, errors, TypeScript safety, and Next.js's unique execution contexts. But here's the thing—this isn't just about writing better code.
In today's development landscape, the quality of your API layer directly impacts your team's velocity and your application's reliability. A well-crafted fetch wrapper becomes the invisible infrastructure that lets your team move fast and ship confidently.
The patterns we've explored—context awareness, proper error handling, TypeScript integration, and thoughtful architecture—aren't just "nice to have" features. They're the difference between a fragile application that breaks in production and a robust system that handles the real world gracefully.
Top comments (3)
Wow, I was looking for exactly this, your wrapper looks amazing, congrats bro!
I didn't quite see the refresh token logic being implement, am I missing something?
hey, from Common Pitfalls: Token refresh hell: Implement proper token refresh logic before you need it
in this article i did not cover refresh token logic, only core structure
i'm planning to make another article or edit this one for better explanation about client / server nextjs with more convenient fetch wrapper and auth implementation