REST and GraphQL API Integration Patterns in React and Next.js
Master API integration in React and Next.js with REST, GraphQL, Apollo Client, error handling, caching, and real-world optimization patterns.
REST and GraphQL API Integration Patterns in React and Next.js
API integration is where frontend meets reality. The elegance of your components doesn't matter if data flows are fragile, slow, or hard to maintain. Having built applications with both REST APIs and GraphQL — including a B2B commerce platform with Apollo Client that achieved 40% faster data loading — I've developed patterns that keep API layers clean, type-safe, and performant. This guide covers both paradigms and when to use each.
REST API Patterns
Type-Safe API Client
Create a centralized client with proper error handling and interceptors:
// lib/api-client.ts
interface ApiConfig {
baseUrl: string;
getToken?: () => string | null;
}
class ApiClient {
private baseUrl: string;
private getToken?: () => string | null;
constructor(config: ApiConfig) {
this.baseUrl = config.baseUrl;
this.getToken = config.getToken;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
const token = this.getToken?.();
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.message || `API error: ${response.status}`);
}
if (response.status === 204) return undefined as T;
return response.json();
}
get<T>(endpoint: string, params?: Record<string, string>) {
const url = params
? `${endpoint}?${new URLSearchParams(params)}`
: endpoint;
return this.request<T>(url);
}
post<T>(endpoint: string, body: unknown) {
return this.request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(body),
});
}
patch<T>(endpoint: string, body: unknown) {
return this.request<T>(endpoint, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
delete<T>(endpoint: string) {
return this.request<T>(endpoint, { method: 'DELETE' });
}
}
export const api = new ApiClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL || '/api',
getToken: () => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('auth_token');
},
});
Resource-Based Service Layer
// services/projectService.ts
import { api } from '@/lib/api-client';
export interface Project {
id: string;
title: string;
description: string;
status: 'draft' | 'active' | 'completed';
technologies: string[];
createdAt: string;
}
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
export const projectService = {
list: (params?: { page?: number; status?: string }) =>
api.get<PaginatedResponse<Project>>('/projects', params as Record<string, string>),
getById: (id: string) =>
api.get<Project>(`/projects/${id}`),
create: (data: Omit<Project, 'id' | 'createdAt'>) =>
api.post<Project>('/projects', data),
update: (id: string, data: Partial<Project>) =>
api.patch<Project>(`/projects/${id}`, data),
delete: (id: string) =>
api.delete(`/projects/${id}`),
};
Next.js Server Actions for Form Mutations
// app/projects/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createProject(formData: FormData) {
const title = formData.get('title') as string;
const description = formData.get('description') as string;
if (!title?.trim()) {
return { error: 'Title is required' };
}
try {
const response = await fetch(`${process.env.API_URL}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description }),
});
if (!response.ok) {
const error = await response.json();
return { error: error.message || 'Failed to create project' };
}
revalidatePath('/projects');
return { success: true };
} catch {
return { error: 'Network error. Please try again.' };
}
}
GraphQL with Apollo Client
Apollo Client Setup for Next.js
// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
});
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) =>
console.error(`[GraphQL error]: ${message}`, { locations, path })
);
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
}
});
const retryLink = new RetryLink({
delay: { initial: 300, max: 3000, jitter: true },
attempts: { max: 3, retryIf: (error) => !!error },
});
export const apolloClient = new ApolloClient({
link: from([retryLink, errorLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
projects: {
keyArgs: ['status'],
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
}),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});
Type-Safe Queries with Code Generation
# graphql/queries/projects.graphql
query GetProjects($status: ProjectStatus, $page: Int, $limit: Int) {
projects(status: $status, page: $page, limit: $limit) {
data {
id
title
description
status
technologies
image
createdAt
}
total
hasNextPage
}
}
query GetProject($id: ID!) {
project(id: $id) {
id
title
description
status
technologies
image
links {
live
github
}
createdAt
updatedAt
}
}
mutation CreateProject($input: CreateProjectInput!) {
createProject(input: $input) {
id
title
status
}
}
Using Generated Hooks
// components/ProjectList.tsx
'use client';
import { useGetProjectsQuery } from '@/generated/graphql';
function ProjectList({ status }: { status?: string }) {
const { data, loading, error, fetchMore } = useGetProjectsQuery({
variables: { status, page: 1, limit: 12 },
notifyOnNetworkStatusChange: true,
});
if (error) return <ErrorMessage error={error} />;
const projects = data?.projects.data ?? [];
const hasNextPage = data?.projects.hasNextPage;
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading && !projects.length
? Array.from({ length: 6 }).map((_, i) => <ProjectSkeleton key={i} />)
: projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
{hasNextPage && (
<button
onClick={() =>
fetchMore({
variables: { page: Math.ceil(projects.length / 12) + 1 },
})
}
disabled={loading}
className="mt-8 px-6 py-3 bg-cyan-600 rounded-lg hover:bg-cyan-700"
>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
REST vs GraphQL: When to Use Which
| Criteria | REST | GraphQL |
|---|---|---|
| Data shape varies per view | Over/under-fetching | Request exact fields |
| Simple CRUD | Natural fit | Overkill |
| Real-time updates | WebSockets/SSE | Subscriptions built-in |
| File uploads | Straightforward | Requires multipart spec |
| Caching | HTTP cache headers | Normalized client cache |
| Team size | Any | Benefits larger teams |
| Mobile + Web | Multiple endpoints | One endpoint, different queries |
My rule of thumb: Use REST for simple APIs and server-to-server communication. Use GraphQL when multiple clients need different data shapes from the same backend — exactly the case in B2B commerce platforms where dashboards, mobile apps, and public pages all consume the same data differently.
Error Handling Patterns
Unified Error Boundary
'use client';
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div className="p-6 bg-red-900/20 border border-red-800 rounded-xl text-center">
<h3 className="text-lg font-semibold text-red-400">Something went wrong</h3>
<p className="text-sm text-gray-400 mt-2">{error.message}</p>
<button
onClick={resetErrorBoundary}
className="mt-4 px-4 py-2 bg-red-600 rounded-lg hover:bg-red-700 text-sm"
>
Try Again
</button>
</div>
);
}
export function QueryErrorBoundary({ children }: { children: React.ReactNode }) {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={reset}>
{children}
</ErrorBoundary>
);
}
Key Takeaways
- Centralized API client — Single source for auth headers, error handling, and base URL
- Service layer per resource — Type-safe functions that abstract HTTP details
- GraphQL for complex data needs — Normalized caching and exact field selection
- Server Actions for mutations — Automatic revalidation with
revalidatePath - Apollo cache policies — Customize merge strategies for pagination
- Error boundaries — Graceful degradation instead of white screens
Clean API integration is what separates hobby projects from production applications. Invest in your data layer, and every component built on top of it becomes simpler and more reliable.