Module 16 — Real API Architecture in Next.js
A production application rarely puts all data-fetching logic directly inside a page component.
Instead, a clean architecture separates the UI, service layer, API, backend, and database.
The architecture we will build is:
1Next.js Page 2 ↓ 3Service Function 4 ↓ 5API 6 ↓ 7Backend 8 ↓ 9Database
For a course platform, the complete request flow can look like:
1Course Page 2 ↓ 3getCourse(slug) 4 ↓ 5GET /api/courses/:slug 6 ↓ 7Django / Node.js API 8 ↓ 9PostgreSQL / MySQL
This separation makes your application easier to maintain, test, secure, and scale.
What Is Real API Architecture?
In a small application, you might write:
1export default async function CoursePage() { 2 const response = await fetch( 3 "https://api.example.com/courses/nextjs" 4 ); 5 6 const course = await response.json(); 7 8 return <h1>{course.title}</h1>; 9}
This works, but it creates a problem.
The page now knows:
- where the API is located
- how the request works
- how authentication works
- how errors are handled
- how the response is parsed
As the application grows, this logic gets repeated across many pages.
A better architecture is:
1Page 2 ↓ 3Service Function 4 ↓ 5API 6 ↓ 7Backend 8 ↓ 9Database
The page only needs to know:
1const course = await getCourse(slug);
Why Use a Service Layer?
The service layer separates application UI from API communication.
Instead of:
1fetch("/api/courses/nextjs")
inside every page, create:
1getCourse("nextjs")
The service function handles the API request.
1Page 2 ↓ 3getCourse() 4 ↓ 5fetch() 6 ↓ 7API
This gives you a single place to manage:
- API URLs
- authentication
- headers
- error handling
- response parsing
- caching
- retries
- request configuration
Recommended Project Structure
A real application could use:
1src/ 2├── app/ 3│ ├── courses/ 4│ │ └── [slug]/ 5│ │ └── page.tsx 6│ │ 7│ └── api/ 8│ └── courses/ 9│ └── [slug]/ 10│ └── route.ts 11│ 12├── services/ 13│ └── course.service.ts 14│ 15├── lib/ 16│ └── api.ts 17│ 18└── types/ 19 └── course.ts
The responsibilities are separated:
1app/ 2 ↓ 3UI and Route Handlers 4 5services/ 6 ↓ 7API communication 8 9lib/ 10 ↓ 11Shared infrastructure 12 13types/ 14 ↓ 15TypeScript types
The Course Page
Suppose we have:
1app/courses/[slug]/page.tsx
A simple page might look like:
1import { getCourse } from "@/services/course.service"; 2 3type Props = { 4 params: Promise<{ 5 slug: string; 6 }>; 7}; 8 9export default async function CoursePage({ 10 params, 11}: Props) { 12 const { slug } = await params; 13 14 const course = await getCourse(slug); 15 16 return ( 17 <main> 18 <h1>{course.title}</h1> 19 20 <p> 21 {course.description} 22 </p> 23 </main> 24 ); 25}
Notice what the page does not contain.
It does not contain:
1API URL 2fetch configuration 3Authorization logic 4Response parsing 5Database code
The page only asks:
1const course = await getCourse(slug);
This is the main idea behind the architecture.
Creating the Service Function
Create:
1services/course.service.ts
Example:
1export async function getCourse( 2 slug: string 3) { 4 const response = await fetch( 5 `${process.env.API_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 page becomes very clean:
1const course = await getCourse(slug);
The architecture is:
1CoursePage 2 ↓ 3getCourse(slug) 4 ↓ 5fetch() 6 ↓ 7Backend API
Creating a Reusable API Client
As the project grows, you may have many services:
1Course API 2User API 3Auth API 4Payment API 5Project API 6Comment API
Instead of repeating fetch() configuration, create:
1lib/api.ts
Example:
1const API_URL = 2 process.env.API_URL; 3 4export async function apiFetch<T>( 5 path: string, 6 options?: RequestInit 7): Promise<T> { 8 const response = await fetch( 9 `${API_URL}${path}`, 10 { 11 ...options, 12 headers: { 13 "Content-Type": 14 "application/json", 15 ...options?.headers, 16 }, 17 } 18 ); 19 20 if (!response.ok) { 21 throw new Error( 22 `API request failed: ${response.status}` 23 ); 24 } 25 26 return response.json(); 27}
Now the service becomes:
1import { apiFetch } from "@/lib/api"; 2 3export async function getCourse( 4 slug: string 5) { 6 return apiFetch( 7 `/courses/${slug}` 8 ); 9}
This creates:
1Course Page 2 ↓ 3Course Service 4 ↓ 5apiFetch() 6 ↓ 7Backend API
Why This Is Better
Without a service layer:
1Page 1 → fetch() 2Page 2 → fetch() 3Page 3 → fetch() 4Page 4 → fetch()
Each page may implement API communication differently.
With a service layer:
1Page 1 ──┐ 2Page 2 ──┤ 3Page 3 ──┼──→ Service Layer → API 4Page 4 ──┘
Now API behavior is centralized.
The API Layer
Next.js can also expose its own API endpoint.
For example:
1app/api/courses/[slug]/route.ts
This creates:
1GET /api/courses/:slug
Example:
1import { NextResponse } from "next/server"; 2 3type Context = { 4 params: Promise<{ 5 slug: string; 6 }>; 7}; 8 9export async function GET( 10 request: Request, 11 context: Context 12) { 13 const { slug } = await context.params; 14 15 const response = await fetch( 16 `${process.env.BACKEND_URL}/courses/${slug}` 17 ); 18 19 if (!response.ok) { 20 return NextResponse.json( 21 { 22 error: "Course not found", 23 }, 24 { 25 status: 404, 26 } 27 ); 28 } 29 30 const course = 31 await response.json(); 32 33 return NextResponse.json(course); 34}
Now the architecture becomes:
1Next.js Page 2 ↓ 3getCourse(slug) 4 ↓ 5GET /api/courses/:slug 6 ↓ 7Django / Node.js API 8 ↓ 9Database
Why Put Next.js Between the Page and Backend?
You may wonder:
Why not call Django or Node.js directly from the page?
Sometimes you should.
But a Next.js API layer can be useful when you need a Backend-for-Frontend (BFF).
For example:
1Browser 2 ↓ 3Next.js 4 ↓ 5Django API 6 ↓ 7PostgreSQL
The Next.js layer can handle:
- authentication
- cookies
- request transformation
- response transformation
- hiding internal API URLs
- combining multiple backend requests
- server-side authorization
- frontend-specific API responses
Backend-for-Frontend Architecture
A larger application might look like:
1 Browser 2 │ 3 ▼ 4 Next.js Application 5 │ 6 ▼ 7 Service Functions 8 │ 9 ▼ 10 Next.js API Layer 11 │ 12 ▼ 13 Django REST API 14 │ 15 ┌──────┴──────┐ 16 ▼ ▼ 17 PostgreSQL Redis
The browser does not need to know about the internal Django API structure.
Direct Backend API Architecture
You do not always need a Next.js API layer.
You could also use:
1Next.js Page 2 ↓ 3Service Function 4 ↓ 5Django / Node.js API 6 ↓ 7PostgreSQL
For example:
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}
This can be a perfectly valid architecture.
The important thing is to understand why you are adding each layer.
Architecture With Django
Suppose your backend is Django REST Framework.
The architecture might be:
1Next.js 2 │ 3 │ GET /api/courses/nextjs 4 ▼ 5Next.js Service 6 │ 7 │ HTTP Request 8 ▼ 9Django REST API 10 │ 11 ▼ 12PostgreSQL
The Django API might expose:
1GET /api/courses/ 2GET /api/courses/nextjs/ 3POST /api/courses/ 4PATCH /api/courses/nextjs/ 5DELETE /api/courses/nextjs/
Next.js does not need to know how Django communicates with PostgreSQL.
That is an important separation of responsibilities.
Architecture With Node.js
The same architecture works with a Node.js backend:
1Next.js 2 ↓ 3Service Function 4 ↓ 5Node.js API 6 ↓ 7PostgreSQL
The frontend architecture does not fundamentally change.
Only the backend implementation changes.
1Django 2Node.js 3Go 4Java 5.NET 6Rust
All can provide the API layer.
Database Should Stay Behind the Backend
A major architectural rule is:
1❌ Browser 2 ↓ 3 Database
Never expose your database directly to the browser.
Instead:
1Browser 2 ↓ 3Next.js 4 ↓ 5Backend API 6 ↓ 7Database
The database should remain inside the trusted server environment.
Why the Database Should Be Server-Side
Database credentials are sensitive.
For example:
1DATABASE_URL=postgresql://...
This should remain server-side.
Never expose database credentials through:
1NEXT_PUBLIC_*
or client-side JavaScript.
A safe architecture is:
1Browser 2 ↓ 3HTTP 4 ↓ 5Server 6 ↓ 7Database
Service Functions Should Hide API Details
A good service 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 "Unable to load course" 11 ); 12 } 13 14 return response.json(); 15}
The page doesn't care whether the backend is:
1Django 2Node.js 3Go 4Java 5.NET
It simply uses:
1const course = await getCourse(slug);
This creates loose coupling.
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 published: boolean; 7};
Then type the service:
1import type { Course } from "@/types/course"; 2 3export async function getCourse( 4 slug: string 5): Promise<Course> { 6 const response = await fetch( 7 `${process.env.BACKEND_URL}/courses/${slug}` 8 ); 9 10 if (!response.ok) { 11 throw new Error( 12 "Failed to fetch course" 13 ); 14 } 15 16 return response.json(); 17}
Now TypeScript understands:
1course.title 2course.description 3course.slug 4course.published
Service Layer for Multiple Operations
A course service might contain:
1import type { Course } from "@/types/course"; 2import { apiFetch } from "@/lib/api"; 3 4export async function getCourse( 5 slug: string 6): Promise<Course> { 7 return apiFetch( 8 `/courses/${slug}` 9 ); 10} 11 12export async function getCourses(): Promise<Course[]> { 13 return apiFetch( 14 `/courses` 15 ); 16} 17 18export async function createCourse( 19 data: { 20 title: string; 21 description: string; 22 } 23): Promise<Course> { 24 return apiFetch( 25 `/courses`, 26 { 27 method: "POST", 28 body: JSON.stringify(data), 29 } 30 ); 31}
Now your application has a clean API abstraction:
1getCourses() 2getCourse(slug) 3createCourse(data) 4updateCourse() 5deleteCourse()
Adding Authentication
Suppose your backend requires a token.
Do not put sensitive credentials directly into a client component.
Instead, server-side code can attach authentication information.
Conceptually:
1User Request 2 ↓ 3Next.js 4 ↓ 5Session 6 ↓ 7Service Function 8 ↓ 9Authorization 10 ↓ 11Backend API
For example:
1export async function getCourse( 2 slug: string 3) { 4 const token = 5 await getServerToken(); 6 7 const response = await fetch( 8 `${process.env.BACKEND_URL}/courses/${slug}`, 9 { 10 headers: { 11 Authorization: 12 `Bearer ${token}`, 13 }, 14 } 15 ); 16 17 if (!response.ok) { 18 throw new Error( 19 "Failed to fetch course" 20 ); 21 } 22 23 return response.json(); 24}
The exact authentication mechanism depends on your application.
Error Handling
A service layer is a good place to standardize errors.
Instead of every page doing:
1if (!response.ok) { 2 // ... 3}
you can centralize the behavior.
1export async function apiFetch<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 Error( 12 `API Error: ${response.status}` 13 ); 14 } 15 16 return response.json(); 17}
Then:
1const course = 2 await getCourse(slug);
automatically gets consistent error handling.
Handling 404 Errors
A course may not exist.
For example:
1/courses/does-not-exist
The backend could return:
1404 Not Found
Your service can detect this:
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.status === 404) { 9 return null; 10 } 11 12 if (!response.ok) { 13 throw new Error( 14 "Failed to fetch course" 15 ); 16 } 17 18 return response.json(); 19}
Then the page can handle it:
1const course = 2 await getCourse(slug); 3 4if (!course) { 5 return <p>Course not found.</p>; 6}
In a production Next.js application, you can also use the appropriate Next.js navigation/error mechanisms for a proper 404 page.
Loading and Error States
Server-rendered pages can use Next.js route-level loading and error UI.
For example:
1app/ 2└── courses/ 3 └── [slug]/ 4 ├── page.tsx 5 ├── loading.tsx 6 └── error.tsx
This gives you:
1Request 2 ↓ 3Loading UI 4 ↓ 5Service Function 6 ↓ 7API 8 ↓ 9Success / Error
This keeps data-fetching states separate from the main page component.
Server Component Architecture
This architecture works especially well with Server Components.
1import { getCourse } from "@/services/course.service"; 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 happens on the server:
1Browser 2 ↓ 3Next.js Server 4 ↓ 5getCourse() 6 ↓ 7Backend API 8 ↓ 9Database
The browser does not need to execute the backend request itself.
Client Component Architecture
Sometimes the browser genuinely needs to communicate with the API.
For example:
1User clicks "Like" 2 ↓ 3Client Component 4 ↓ 5POST /api/courses/123/like 6 ↓ 7Backend 8 ↓ 9Database
Example:
1"use client"; 2 3export default function LikeButton({ 4 courseId, 5}: { 6 courseId: string; 7}) { 8 async function handleLike() { 9 const response = await fetch( 10 `/api/courses/${courseId}/like`, 11 { 12 method: "POST", 13 } 14 ); 15 16 if (!response.ok) { 17 throw new Error( 18 "Failed to like course" 19 ); 20 } 21 } 22 23 return ( 24 <button onClick={handleLike}> 25 Like 26 </button> 27 ); 28}
This is a good example of where client-side API communication makes sense.
Server Fetch vs Client Fetch
The decision can be summarized as:
1Need initial page data? 2 ↓ 3 Server Fetch 4 ↓ 5 Yes
For example:
1Course details 2Blog article 3Product information 4Documentation 5SEO content
Client fetching is more appropriate for interactions such as:
1Like button 2Search 3Filters 4Live updates 5Infinite scrolling 6Interactive dashboards
The goal is not to eliminate client fetching.
The goal is to use it where it provides value.
Avoiding Unnecessary API Hops
One important architectural consideration:
1Server Component 2 ↓ 3Next.js API 4 ↓ 5Django API 6 ↓ 7Database
This creates an additional network hop.
If the Server Component can safely call the backend service directly:
1Server Component 2 ↓ 3Django API 4 ↓ 5Database
that can be simpler and more efficient.
Therefore:
Do not create a Next.js API layer automatically. Create it when it provides a real architectural benefit.
When a Next.js API Layer Makes Sense
A Next.js API layer is useful when you need:
1Authentication boundary 2 ↓ 3Request transformation 4 ↓ 5Response transformation 6 ↓ 7Multiple backend APIs 8 ↓ 9Frontend-specific API 10 ↓ 11Hide internal services
For example:
1Next.js API 2 │ 3 ├── Django API 4 │ 5 ├── Payment API 6 │ 7 └── Search API
The frontend can communicate with one consistent interface.
Combining Multiple APIs
Imagine a course page needs:
1Course 2Instructor 3Reviews 4Recommendations
Instead of making four requests from the browser:
1Browser 2 ├── Course API 3 ├── Instructor API 4 ├── Review API 5 └── Recommendation API
a server-side layer can aggregate them:
1Browser 2 ↓ 3Next.js API 4 ├── Course API 5 ├── Instructor API 6 ├── Review API 7 └── Recommendation API 8 ↓ 9Combined Response
The response could become:
1{ 2 "course": {}, 3 "instructor": {}, 4 "reviews": [], 5 "recommendations": [] 6}
This pattern can reduce frontend complexity.
Real Production Architecture
A large application might look like this:
1 Browser 2 │ 3 ▼ 4 Next.js Application 5 │ 6 ┌─────────────┴─────────────┐ 7 │ │ 8 Server Pages Client Components 9 │ │ 10 ▼ ▼ 11 Service Functions API Requests 12 │ │ 13 └─────────────┬─────────────┘ 14 ▼ 15 Next.js API 16 (optional) 17 │ 18 ▼ 19 Backend Services 20 ┌──────┼──────┐ 21 │ │ │ 22 Django Node Other API 23 │ │ 24 └──┬───┘ 25 ▼ 26 PostgreSQL
This is a scalable way to think about full-stack applications.
Complete Course Request
Let's trace one request from beginning to end.
The user visits:
1/courses/nextjs
Step 1 — Next.js Page
1const course = 2 await getCourse("nextjs");
Step 2 — Service Function
1getCourse("nextjs")
calls:
1GET /courses/nextjs
Step 3 — API
The request reaches:
1Django / Node.js API
Step 4 — Backend
The backend validates the request and queries the database.
1Backend 2 ↓ 3Course Model 4 ↓ 5PostgreSQL
Step 5 — Database
PostgreSQL returns:
1{ 2 "id": "123", 3 "slug": "nextjs", 4 "title": "Complete Next.js Course" 5}
Step 6 — Response
The data travels back:
1PostgreSQL 2 ↓ 3Backend 4 ↓ 5API 6 ↓ 7Service Function 8 ↓ 9Next.js Page
Step 7 — Rendering
Next.js renders:
1<h1> 2 Complete Next.js Course 3</h1>
The complete request is:
1User 2 ↓ 3Next.js Page 4 ↓ 5getCourse(slug) 6 ↓ 7API 8 ↓ 9Backend 10 ↓ 11PostgreSQL 12 ↓ 13Backend 14 ↓ 15API 16 ↓ 17getCourse() 18 ↓ 19Next.js 20 ↓ 21HTML 22 ↓ 23Browser
Separation of Responsibilities
A good architecture gives every layer a clear responsibility.
| Layer | Responsibility |
|---|---|
| Page | Render UI |
| Component | Display and interact |
| Service | Communicate with APIs |
| Route Handler | Handle HTTP requests |
| Backend | Business logic |
| Database | Persist data |
This prevents one file from becoming responsible for everything.
Bad Architecture
Avoid putting everything inside page.tsx:
1export default async function Page() { 2 // Authentication 3 4 // API URL 5 6 // Fetch request 7 8 // Validation 9 10 // Database logic 11 12 // Business logic 13 14 // Error handling 15 16 // UI 17 18 return ( 19 <div> 20 ... 21 </div> 22 ); 23}
As the application grows, this becomes difficult to maintain.
Better Architecture
Separate the responsibilities:
1page.tsx 2 ↓ 3course.service.ts 4 ↓ 5api.ts 6 ↓ 7API 8 ↓ 9Backend 10 ↓ 11Database
Then:
page.tsx
1const course = 2 await getCourse(slug);
course.service.ts
1return apiFetch( 2 `/courses/${slug}` 3);
api.ts
1return fetch( 2 `${API_URL}${path}` 3);
Backend
1Validate 2 ↓ 3Business Logic 4 ↓ 5Database
This is much easier to reason about.
Important Security Boundary
One of the most important concepts in this architecture is the security boundary.
1 PUBLIC 2──────────────────────────────── 3Browser 4 ↓ 5Next.js UI 6──────────────────────────────── 7 SERVER 8 ↓ 9Service Layer 10 ↓ 11API 12 ↓ 13Backend 14 ↓ 15Database 16──────────────────────────────── 17 PRIVATE
Private credentials should remain on the server:
1DATABASE_URL 2API_SECRET 3PRIVATE_API_KEY 4JWT_SECRET
Never expose these to browser code.
Environment Variables
For server-side API configuration:
1BACKEND_URL=https://api.example.com 2API_SECRET=your-secret 3DATABASE_URL=postgresql://...
Access them from server code:
1const backendUrl = 2 process.env.BACKEND_URL;
Avoid exposing secrets through:
1NEXT_PUBLIC_API_SECRET=...
Anything prefixed with NEXT_PUBLIC_ is intended to be available to browser-side code.
Architecture Rules to Remember
Rule 1 — Keep UI separate from API logic
1UI 2 ↓ 3Service 4 ↓ 5API
Rule 2 — Keep database access server-side
1Server 2 ↓ 3Database
Rule 3 — Never trust client input
1Request 2 ↓ 3Validate 4 ↓ 5Process
Rule 4 — Protect authentication
1Request 2 ↓ 3Authenticate 4 ↓ 5Authorize
Rule 5 — Don't add unnecessary layers
1Server Component 2 ↓ 3Direct backend API
can be better than:
1Server Component 2 ↓ 3Next.js API 4 ↓ 5Backend API
when the extra layer provides no benefit.
Final Architecture
The core architecture for this module is:
1 Next.js Page 2 │ 3 ▼ 4 Service Function 5 │ 6 ▼ 7 API Request 8 │ 9 ▼ 10 Next.js API Layer 11 (optional) 12 │ 13 ▼ 14 Django / Node.js 15 Backend 16 │ 17 ▼ 18 PostgreSQL / MySQL
For the course example:
1Course Page 2 ↓ 3getCourse(slug) 4 ↓ 5GET /api/courses/:slug 6 ↓ 7Django / Node.js API 8 ↓ 9PostgreSQL / MySQL
The most important idea is:
The page should focus on rendering, the service layer should focus on API communication, the backend should focus on business logic, and the database should focus on persistence.
This separation gives you a codebase that is cleaner, more secure, easier to test, easier to replace, and easier to scale.
Module 16 Learning Checklist
After completing this module, you should understand:
- What a service layer is
- Why pages should not contain repeated API logic
- How to create reusable API functions
- How Next.js communicates with Django or Node.js
- When to use a Next.js API layer
- When direct backend fetching is better
- How Server Components fit into the architecture
- How Client Components communicate with APIs
- How authentication fits into the request flow
- How to protect database credentials
- How to separate UI, API, backend, and database responsibilities
- How to design a scalable full-stack Next.js architecture