Module 18 — API Service Functions in Next.js
As a Next.js application grows, API requests can quickly become difficult to manage if every request is written directly inside page.tsx.
A better approach is to create API service functions that keep data-fetching logic separate from UI components.
The recommended architecture is:
1Next.js Page 2 ↓ 3Service Function 4 ↓ 5API Client 6 ↓ 7Backend API 8 ↓ 9Database
For example:
1lib/ 2└── api/ 3 ├── courses.ts 4 ├── users.ts 5 ├── topics.ts 6 └── auth.ts
Then a page can simply call:
1const course = await getCourse(slug);
instead of containing the complete HTTP request.
Why API Service Functions Matter
Consider a page that directly contains API logic:
1export default async function CoursePage() { 2 const response = await fetch( 3 "https://api.example.com/courses/nextjs" 4 ); 5 6 if (!response.ok) { 7 throw new Error("Failed to fetch course"); 8 } 9 10 const course = await response.json(); 11 12 return ( 13 <article> 14 <h1>{course.title}</h1> 15 </article> 16 ); 17}
This works for a small project.
However, imagine having:
1100 pages 250 API endpoints 310 different developers 4Multiple authentication requirements 5Different error-handling rules
If every page implements its own API request, the codebase becomes difficult to maintain.
Instead, move the API logic into a service:
1page.tsx 2 ↓ 3getCourse() 4 ↓ 5API
The Core Architecture
A clean application can use:
1app/ 2 ↓ 3UI and Pages 4 5lib/api/ 6 ↓ 7API Service Functions 8 9Backend API 10 ↓ 11Business Logic 12 13Database 14 ↓ 15Persistent Data
For example:
1Course Page 2 ↓ 3getCourse(slug) 4 ↓ 5courses.ts 6 ↓ 7API 8 ↓ 9Django / Node.js 10 ↓ 11PostgreSQL
Each layer has a specific responsibility.
Recommended Project Structure
A practical project structure is:
1src/ 2├── app/ 3│ ├── courses/ 4│ │ └── [slug]/ 5│ │ └── page.tsx 6│ │ 7│ └── users/ 8│ └── page.tsx 9│ 10├── lib/ 11│ └── api/ 12│ ├── client.ts 13│ ├── courses.ts 14│ ├── users.ts 15│ ├── topics.ts 16│ └── auth.ts 17│ 18├── types/ 19│ ├── course.ts 20│ ├── user.ts 21│ └── topic.ts 22│ 23└── components/ 24 ├── CourseCard.tsx 25 └── UserCard.tsx
The important separation is:
1page.tsx 2 ↓ 3lib/api/courses.ts 4 ↓ 5lib/api/client.ts 6 ↓ 7Backend API
Creating the Course Service
Create:
1lib/api/courses.ts
Start with a simple function:
1export async function getCourse( 2 slug: string 3) { 4 const response = await fetch( 5 `${process.env.BACKEND_URL}/courses/${slug}` 6 ); 7 8 if (!response.ok) { 9 throw new Error( 10 "Failed to fetch course" 11 ); 12 } 13 14 return response.json(); 15}
Now the API request is no longer inside the page.
Using the Service in page.tsx
The page becomes much simpler:
1import { getCourse } from "@/lib/api/courses"; 2 3export default async function CoursePage({ 4 params, 5}: { 6 params: Promise<{ 7 slug: string; 8 }>; 9}) { 10 const { slug } = await params; 11 12 const course = 13 await getCourse(slug); 14 15 return ( 16 <article> 17 <h1>{course.title}</h1> 18 19 <p> 20 {course.description} 21 </p> 22 </article> 23 ); 24}
The page now focuses primarily on rendering.
The API implementation lives elsewhere.
Before and After
Without Service Functions
1page.tsx 2 ├── API URL 3 ├── fetch() 4 ├── headers 5 ├── authentication 6 ├── error handling 7 ├── JSON parsing 8 └── UI
This creates a large page component.
With Service Functions
1page.tsx 2 └── UI 3 4courses.ts 5 └── Course API 6 7client.ts 8 └── HTTP logic
This creates much cleaner separation.
Creating a Shared API Client
If every service repeats:
1const response = await fetch(...); 2 3if (!response.ok) { 4 throw new Error(...); 5}
you can centralize the common behavior.
Create:
1lib/api/client.ts
1export async function apiClient<T>( 2 path: string, 3 options?: RequestInit 4): Promise<T> { 5 const response = await fetch( 6 `${process.env.BACKEND_URL}${path}`, 7 { 8 ...options, 9 headers: { 10 "Content-Type": 11 "application/json", 12 ...options?.headers, 13 }, 14 } 15 ); 16 17 if (!response.ok) { 18 throw new Error( 19 `API request failed: ${response.status}` 20 ); 21 } 22 23 return response.json(); 24}
Now your service functions become smaller.
Using the Shared API Client
Update:
1lib/api/courses.ts
to:
1import { apiClient } from "./client"; 2 3export function getCourse( 4 slug: string 5) { 6 return apiClient( 7 `/courses/${slug}` 8 ); 9}
The architecture is now:
1page.tsx 2 ↓ 3getCourse() 4 ↓ 5apiClient() 6 ↓ 7Backend API
This is much easier to scale.
Adding TypeScript Types
Define a course type:
1types/course.ts
1export type Course = { 2 id: string; 3 slug: string; 4 title: string; 5 description: string; 6 price: number; 7};
Now make the API client generic:
1export async function apiClient<T>( 2 path: string, 3 options?: RequestInit 4): Promise<T> { 5 const response = await fetch( 6 `${process.env.BACKEND_URL}${path}`, 7 { 8 ...options, 9 headers: { 10 "Content-Type": 11 "application/json", 12 ...options?.headers, 13 }, 14 } 15 ); 16 17 if (!response.ok) { 18 throw new Error( 19 `API request failed: ${response.status}` 20 ); 21 } 22 23 return response.json(); 24}
Then type the service:
1import type { Course } from "@/types/course"; 2import { apiClient } from "./client"; 3 4export function getCourse( 5 slug: string 6): Promise<Course> { 7 return apiClient<Course>( 8 `/courses/${slug}` 9 ); 10}
Now the entire request is type-safe:
1API 2 ↓ 3apiClient<Course>() 4 ↓ 5Promise<Course> 6 ↓ 7getCourse() 8 ↓ 9Course Page
Getting Multiple Courses
The service file can contain related functions:
1import type { Course } from "@/types/course"; 2import { apiClient } from "./client"; 3 4export function getCourses(): Promise<Course[]> { 5 return apiClient<Course[]>( 6 "/courses" 7 ); 8} 9 10export function getCourse( 11 slug: string 12): Promise<Course> { 13 return apiClient<Course>( 14 `/courses/${slug}` 15 ); 16}
Now pages can use either:
1const courses = 2 await getCourses();
or:
1const course = 2 await getCourse(slug);
Creating a Course
Service functions are not limited to GET requests.
You can create:
1export function createCourse( 2 data: { 3 title: string; 4 description: string; 5 price: number; 6 } 7): Promise<Course> { 8 return apiClient<Course>( 9 "/courses", 10 { 11 method: "POST", 12 body: JSON.stringify(data), 13 } 14 ); 15}
Now your application has:
1getCourses() 2getCourse() 3createCourse()
Updating a Course
For PATCH:
1export function updateCourse( 2 slug: string, 3 data: Partial<Course> 4): Promise<Course> { 5 return apiClient<Course>( 6 `/courses/${slug}`, 7 { 8 method: "PATCH", 9 body: JSON.stringify(data), 10 } 11 ); 12}
Usage:
1await updateCourse( 2 "nextjs", 3 { 4 title: "Advanced Next.js", 5 } 6);
Only the fields that need to change have to be supplied.
Deleting a Course
A delete service might be:
1export function deleteCourse( 2 slug: string 3): Promise<void> { 4 return apiClient<void>( 5 `/courses/${slug}`, 6 { 7 method: "DELETE", 8 } 9 ); 10}
Now the page or action does not need to know how the HTTP request works.
It simply calls:
1await deleteCourse(slug);
Organizing Services by Domain
Don't create one huge file:
1lib/api.ts
containing hundreds of functions.
Instead, organize APIs around application domains:
1lib/ 2└── api/ 3 ├── courses.ts 4 ├── users.ts 5 ├── topics.ts 6 ├── auth.ts 7 ├── projects.ts 8 └── comments.ts
For example:
1courses.ts 2 ↓ 3getCourse() 4getCourses() 5createCourse() 6updateCourse() 7deleteCourse()
And:
1users.ts 2 ↓ 3getUser() 4getUsers() 5updateUser() 6deleteUser()
This keeps the project organized.
Course Service Example
A complete course service could look like:
1import type { Course } from "@/types/course"; 2import { apiClient } from "./client"; 3 4export function getCourses(): Promise<Course[]> { 5 return apiClient<Course[]>( 6 "/courses" 7 ); 8} 9 10export function getCourse( 11 slug: string 12): Promise<Course> { 13 return apiClient<Course>( 14 `/courses/${slug}` 15 ); 16} 17 18export function createCourse( 19 data: Omit<Course, "id"> 20): Promise<Course> { 21 return apiClient<Course>( 22 "/courses", 23 { 24 method: "POST", 25 body: JSON.stringify(data), 26 } 27 ); 28} 29 30export function updateCourse( 31 slug: string, 32 data: Partial<Course> 33): Promise<Course> { 34 return apiClient<Course>( 35 `/courses/${slug}`, 36 { 37 method: "PATCH", 38 body: JSON.stringify(data), 39 } 40 ); 41} 42 43export function deleteCourse( 44 slug: string 45): Promise<void> { 46 return apiClient<void>( 47 `/courses/${slug}`, 48 { 49 method: "DELETE", 50 } 51 ); 52}
Now all course-related API operations live in one place.
Using Omit for Create Operations
Notice:
1Omit<Course, "id">
The database might generate the ID.
Therefore, when creating a course, the client shouldn't need to provide:
1id
The type:
1Omit<Course, "id">
means:
1Course 2 ├── id ← removed 3 ├── slug 4 ├── title 5 ├── description 6 └── price
This is a good example of TypeScript utility types improving API design.
Using Partial for Updates
For PATCH operations:
1Partial<Course>
makes every property optional.
For example:
1{ 2 title?: string; 3 description?: string; 4 price?: number; 5}
This is appropriate because a PATCH request might update only one field:
1await updateCourse( 2 "nextjs", 3 { 4 price: 799, 5 } 6);
API Service for Users
Create:
1lib/api/users.ts
1import type { User } from "@/types/user"; 2import { apiClient } from "./client"; 3 4export function getUser( 5 id: string 6): Promise<User> { 7 return apiClient<User>( 8 `/users/${id}` 9 ); 10} 11 12export function getUsers(): Promise<User[]> { 13 return apiClient<User[]>( 14 "/users" 15 ); 16}
Now user pages can use:
1const user = 2 await getUser(userId);
without knowing how the HTTP request works.
API Service for Topics
Create:
1lib/api/topics.ts
1import type { Topic } from "@/types/topic"; 2import { apiClient } from "./client"; 3 4export function getTopics(): Promise<Topic[]> { 5 return apiClient<Topic[]>( 6 "/topics" 7 ); 8} 9 10export function getTopic( 11 slug: string 12): Promise<Topic> { 13 return apiClient<Topic>( 14 `/topics/${slug}` 15 ); 16}
Now the architecture stays consistent:
1Courses 2 ↓ 3courses.ts 4 5Users 6 ↓ 7users.ts 8 9Topics 10 ↓ 11topics.ts 12 13Authentication 14 ↓ 15auth.ts
Authentication Service
Authentication can also have its own service.
1lib/api/auth.ts
For example:
1import { apiClient } from "./client"; 2 3type LoginResponse = { 4 accessToken: string; 5 user: { 6 id: string; 7 name: string; 8 }; 9}; 10 11export function login( 12 email: string, 13 password: string 14): Promise<LoginResponse> { 15 return apiClient<LoginResponse>( 16 "/auth/login", 17 { 18 method: "POST", 19 body: JSON.stringify({ 20 email, 21 password, 22 }), 23 } 24 ); 25}
Now authentication logic isn't scattered throughout your pages.
Server Components and Services
API service functions work especially well with Server Components.
Example:
1import { getCourse } from "@/lib/api/courses"; 2 3export default async function CoursePage({ 4 params, 5}: { 6 params: Promise<{ 7 slug: string; 8 }>; 9}) { 10 const { slug } = await params; 11 12 const course = 13 await getCourse(slug); 14 15 return ( 16 <article> 17 <h1>{course.title}</h1> 18 19 <p> 20 {course.description} 21 </p> 22 </article> 23 ); 24}
The request flow is:
1Server Component 2 ↓ 3getCourse() 4 ↓ 5apiClient() 6 ↓ 7Backend API 8 ↓ 9Database
Client Components and Services
Client Components can also use service functions, but you must consider where the function executes.
For example:
1"use client"; 2 3import { useState } from "react"; 4 5export function LikeButton() { 6 const [liked, setLiked] = 7 useState(false); 8 9 async function handleLike() { 10 await fetch( 11 "/api/courses/123/like", 12 { 13 method: "POST", 14 } 15 ); 16 17 setLiked(true); 18 } 19 20 return ( 21 <button onClick={handleLike}> 22 {liked ? "Liked" : "Like"} 23 </button> 24 ); 25}
For browser-executed services, the service must not expose server-only secrets.
A common pattern is to call a public Next.js route handler:
1Client Component 2 ↓ 3Browser-safe API Service 4 ↓ 5Next.js Route Handler 6 ↓ 7Backend API
Server-Only API Services
Some service functions should only run on the server.
For example:
1import "server-only"; 2 3export async function getPrivateCourse( 4 slug: string 5) { 6 const token = 7 process.env.INTERNAL_API_TOKEN; 8 9 const response = await fetch( 10 `${process.env.BACKEND_URL}/courses/${slug}`, 11 { 12 headers: { 13 Authorization: 14 `Bearer ${token}`, 15 }, 16 } 17 ); 18 19 if (!response.ok) { 20 throw new Error( 21 "Failed to fetch course" 22 ); 23 } 24 25 return response.json(); 26}
The:
1import "server-only";
directive helps prevent accidental use of this module in client-side code.
This is especially useful when the service accesses:
1Private API keys 2Database credentials 3Internal services 4Server-only environment variables
API Service Error Handling
A service layer is also a good place to standardize errors.
Instead of:
1throw new Error( 2 "Something went wrong" 3);
everywhere, you can create a custom error.
1export class ApiError 2 extends Error { 3 constructor( 4 public status: number, 5 message: string 6 ) { 7 super(message); 8 } 9}
Then:
1export async function apiClient<T>( 2 path: string, 3 options?: RequestInit 4): Promise<T> { 5 const response = await fetch( 6 `${process.env.BACKEND_URL}${path}`, 7 options 8 ); 9 10 if (!response.ok) { 11 throw new ApiError( 12 response.status, 13 `API request failed: ${response.status}` 14 ); 15 } 16 17 return response.json(); 18}
Now callers can distinguish errors:
1try { 2 const course = 3 await getCourse(slug); 4} catch (error) { 5 if ( 6 error instanceof ApiError 7 ) { 8 console.error( 9 error.status 10 ); 11 } 12}
Handling 404 Responses
A service can explicitly handle a missing resource.
1export async function getCourse( 2 slug: string 3): Promise<Course | null> { 4 try { 5 return await apiClient<Course>( 6 `/courses/${slug}` 7 ); 8 } catch (error) { 9 if ( 10 error instanceof ApiError && 11 error.status === 404 12 ) { 13 return null; 14 } 15 16 throw error; 17 } 18}
Now the page can decide what to display:
1const course = 2 await getCourse(slug); 3 4if (!course) { 5 return ( 6 <h1> 7 Course not found 8 </h1> 9 ); 10}
Request Configuration
A shared API client can also manage common configuration:
1export async function apiClient<T>( 2 path: string, 3 options?: RequestInit 4): Promise<T> { 5 return fetch( 6 `${process.env.BACKEND_URL}${path}`, 7 { 8 ...options, 9 headers: { 10 Accept: 11 "application/json", 12 "Content-Type": 13 "application/json", 14 ...options?.headers, 15 }, 16 } 17 ).then(async (response) => { 18 if (!response.ok) { 19 throw new Error( 20 `HTTP ${response.status}` 21 ); 22 } 23 24 return response.json(); 25 }); 26}
Now every service automatically receives the same headers.
Authentication Headers
If your architecture uses bearer authentication, a server-side client could add a token:
1import "server-only"; 2 3export async function apiClient<T>( 4 path: string, 5 options?: RequestInit 6): Promise<T> { 7 const token = 8 process.env.INTERNAL_API_TOKEN; 9 10 const response = await fetch( 11 `${process.env.BACKEND_URL}${path}`, 12 { 13 ...options, 14 headers: { 15 Accept: 16 "application/json", 17 Authorization: 18 `Bearer ${token}`, 19 ...options?.headers, 20 }, 21 } 22 ); 23 24 if (!response.ok) { 25 throw new Error( 26 `HTTP ${response.status}` 27 ); 28 } 29 30 return response.json(); 31}
This keeps the secret on the server.
Do Not Expose Secrets
Never do this:
1const API_KEY = 2 process.env.NEXT_PUBLIC_API_KEY;
for a secret API key.
The NEXT_PUBLIC_ prefix indicates that the value may be exposed to client-side JavaScript.
For private credentials, prefer server-only environment variables:
1BACKEND_URL=https://api.example.com 2INTERNAL_API_TOKEN=secret-value 3DATABASE_URL=postgresql://...
And access them only from server-side code.
Service Functions and Caching
Service functions are also a convenient place to define request behavior.
For example:
1export function getCourses() { 2 return apiClient<Course[]>( 3 "/courses", 4 { 5 next: { 6 revalidate: 3600, 7 }, 8 } 9 ); 10}
This expresses:
1Course API 2 ↓ 3Cache 4 ↓ 5Revalidate periodically
The exact caching strategy depends on whether your data is:
1Static 2Semi-static 3Dynamic 4User-specific 5Frequently changing
Don't cache sensitive or highly dynamic data accidentally.
Service Functions for Search
Suppose your application supports course search.
Create:
1export function searchCourses( 2 query: string 3): Promise<Course[]> { 4 const params = 5 new URLSearchParams({ 6 q: query, 7 }); 8 9 return apiClient<Course[]>( 10 `/courses/search?${params.toString()}` 11 ); 12}
Then:
1const courses = 2 await searchCourses("Next.js");
The page doesn't need to construct the URL manually.
Service Functions for Pagination
A service can also hide pagination details.
1export function getCourses( 2 page = 1, 3 limit = 20 4): Promise<Course[]> { 5 const params = 6 new URLSearchParams({ 7 page: String(page), 8 limit: String(limit), 9 }); 10 11 return apiClient<Course[]>( 12 `/courses?${params.toString()}` 13 ); 14}
Usage:
1const courses = 2 await getCourses(2, 20);
The service handles URL construction.
Service Functions Are an Abstraction
The page knows:
1getCourse(slug)
It does not need to know:
1HTTP method 2API URL 3Headers 4Authentication 5JSON parsing 6Error handling 7Query-string construction
That is abstraction.
1Page 2 ↓ 3"What data do I need?" 4 5Service 6 ↓ 7"How do I get it?" 8 9API Client 10 ↓ 11"How do I communicate over HTTP?" 12 13Backend 14 ↓ 15"How do I process the request?" 16 17Database 18 ↓ 19"How do I store the data?"
This separation is one of the most useful architectural patterns in full-stack applications.
Avoid Over-Abstraction
Service functions are useful, but don't create unnecessary complexity.
For a simple one-page application:
1const response = 2 await fetch("/api/courses");
may be perfectly reasonable.
You don't need:
1Page 2 ↓ 3Hook 4 ↓ 5Service 6 ↓ 7Repository 8 ↓ 9API Client 10 ↓ 11Adapter 12 ↓ 13API
unless your application actually benefits from those layers.
A good architecture should reduce complexity, not create it.
Recommended Architecture
For a medium-to-large Next.js application:
1src/ 2├── app/ 3│ ├── courses/ 4│ │ └── [slug]/ 5│ │ └── page.tsx 6│ │ 7│ └── users/ 8│ └── page.tsx 9│ 10├── lib/ 11│ └── api/ 12│ ├── client.ts 13│ ├── courses.ts 14│ ├── users.ts 15│ ├── topics.ts 16│ └── auth.ts 17│ 18├── types/ 19│ ├── course.ts 20│ ├── user.ts 21│ └── topic.ts 22│ 23└── components/
The flow becomes:
1 PAGE 2 │ 3 ▼ 4 SERVICE FUNCTION 5 │ 6 ▼ 7 API CLIENT 8 │ 9 ▼ 10 BACKEND API 11 │ 12 ▼ 13 DATABASE
Complete Example
types/course.ts
1export type Course = { 2 id: string; 3 slug: string; 4 title: string; 5 description: string; 6 price: number; 7};
lib/api/client.ts
1export async function apiClient<T>( 2 path: string, 3 options?: RequestInit 4): Promise<T> { 5 const response = 6 await fetch( 7 `${process.env.BACKEND_URL}${path}`, 8 { 9 ...options, 10 headers: { 11 "Content-Type": 12 "application/json", 13 ...options?.headers, 14 }, 15 } 16 ); 17 18 if (!response.ok) { 19 throw new Error( 20 `API request failed: ${response.status}` 21 ); 22 } 23 24 return response.json(); 25}
lib/api/courses.ts
1import type { Course } from "@/types/course"; 2import { apiClient } from "./client"; 3 4export function getCourse( 5 slug: string 6): Promise<Course> { 7 return apiClient<Course>( 8 `/courses/${slug}` 9 ); 10}
app/courses/[slug]/page.tsx
1import { getCourse } from "@/lib/api/courses"; 2 3export default async function CoursePage({ 4 params, 5}: { 6 params: Promise<{ 7 slug: string; 8 }>; 9}) { 10 const { slug } = await params; 11 12 const course = 13 await getCourse(slug); 14 15 return ( 16 <main> 17 <h1>{course.title}</h1> 18 19 <p> 20 {course.description} 21 </p> 22 23 <p> 24 Price: ₹{course.price} 25 </p> 26 </main> 27 ); 28}
The final request flow is:
1Browser 2 ↓ 3Course Page 4 ↓ 5getCourse(slug) 6 ↓ 7apiClient<Course>() 8 ↓ 9Backend API 10 ↓ 11Database
This is clean, reusable, and easy to extend.
API Service Architecture Checklist
Before creating an API service layer, ask:
1. Is this request reused?
If yes, create a service function.
2. Does the request contain common configuration?
If yes, centralize it in an API client.
3. Does the API return structured data?
Create a TypeScript type.
4. Does the request require secrets?
Keep it server-side.
5. Does the API have multiple operations?
Group related operations by domain.
6. Does the API need runtime validation?
Validate external data before trusting it.
7. Is the service becoming too complex?
Split responsibilities rather than creating one giant service.
Module 18 Learning Checklist
After completing this module, you should understand:
- Why API requests should not be repeated inside
page.tsx - What API service functions are
- How to create
lib/api/ - How to organize services by domain
- How to create a reusable API client
- How to type API responses
- How to implement GET requests
- How to implement POST requests
- How to implement PATCH requests
- How to implement DELETE requests
- How to use
Partial - How to use
Omit - How to handle API errors
- How to handle 404 responses
- How authentication fits into service functions
- How to protect server-side API credentials
- How service functions work with Server Components
- How Client Components should communicate with APIs
- How caching can be configured
- How to avoid unnecessary abstraction
- How to build a scalable API service architecture
Final Mental Model
Remember this architecture:
1page.tsx 2 │ 3 │ "I need course data" 4 ▼ 5getCourse(slug) 6 │ 7 │ "I know how to request it" 8 ▼ 9apiClient<Course>() 10 │ 11 │ "I know how to communicate" 12 ▼ 13Backend API 14 │ 15 │ "I execute business logic" 16 ▼ 17Database
The key principle is:
Pages should focus on rendering, service functions should focus on domain-specific API operations, and the shared API client should handle common HTTP behavior.
This structure keeps your Next.js codebase clean, reusable, type-safe, testable, and easier to scale.