Next.js Complete Guide Part 4: API Routes and Full-Stack Development
Learn to build full-stack applications with Next.js API routes. Master backend development, database integration, authentication, and middleware with practical examples.
Welcome to Part 4! Now we'll transform your Next.js knowledge into full-stack development skills by mastering API routes, database integration, and authentication.
What are API Routes?
API routes allow you to build backend functionality directly in your Next.js application. They run on the server and can handle database operations, authentication, external API calls, and more.
Key Benefits:
- Full-stack in one project - Frontend and backend together
- Serverless by default - Automatic scaling
- TypeScript support - End-to-end type safety
- Built-in optimizations - Caching and performance
Creating Your First API Route
API routes live in the pages/api directory and export a default function.
Basic API Route
Create pages/api/hello.js:
export default function handler(req, res) {
res.status(200).json({
message: 'Hello from Next.js API!',
timestamp: new Date().toISOString()
})
}
Test it:
- Visit:
http://localhost:3000/api/hello - Returns:
{"message": "Hello from Next.js API!", "timestamp": "2024-01-15T10:30:00.000Z"}
Handling Different HTTP Methods
// pages/api/users.js
export default function handler(req, res) {
const { method } = req
switch (method) {
case 'GET':
// Handle GET request
return res.status(200).json({ users: [] })
case 'POST':
// Handle POST request
const { name, email } = req.body
return res.status(201).json({
message: 'User created',
user: { name, email }
})
case 'PUT':
// Handle PUT request
return res.status(200).json({ message: 'User updated' })
case 'DELETE':
// Handle DELETE request
return res.status(200).json({ message: 'User deleted' })
default:
res.setHeader('Allow', ['GET', 'POST', 'PUT', 'DELETE'])
return res.status(405).end(`Method ${method} Not Allowed`)
}
}
Dynamic API Routes
Just like pages, API routes support dynamic routing.
Single Dynamic Parameter
Create pages/api/users/[id].js:
export default function handler(req, res) {
const { id } = req.query
const { method } = req
switch (method) {
case 'GET':
// Get user by ID
return res.status(200).json({
id,
name: `User ${id}`,
email: `user${id}@example.com`
})
case 'PUT':
// Update user
const { name, email } = req.body
return res.status(200).json({
id,
name,
email,
message: 'User updated successfully'
})
case 'DELETE':
// Delete user
return res.status(200).json({
message: `User ${id} deleted successfully`
})
default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE'])
return res.status(405).end(`Method ${method} Not Allowed`)
}
}
Catch-All API Routes
Create pages/api/blog/[...slug].js:
export default function handler(req, res) {
const { slug } = req.query
// slug is an array: /api/blog/2024/01/my-post → ['2024', '01', 'my-post']
const [year, month, postSlug] = slug || []
if (!year || !month || !postSlug) {
return res.status(400).json({ error: 'Invalid blog post path' })
}
return res.status(200).json({
year,
month,
slug: postSlug,
url: `/blog/${year}/${month}/${postSlug}`
})
}
Database Integration
Let's integrate a database to build real functionality.
Setting Up MongoDB
Install dependencies:
npm install mongodb
Create lib/mongodb.js:
import { MongoClient } from 'mongodb'
const uri = process.env.MONGODB_URI
const options = {}
let client
let clientPromise
if (!process.env.MONGODB_URI) {
throw new Error('Please add your Mongo URI to .env.local')
}
if (process.env.NODE_ENV === 'development') {
if (!global._mongoClientPromise) {
client = new MongoClient(uri, options)
global._mongoClientPromise = client.connect()
}
clientPromise = global._mongoClientPromise
} else {
client = new MongoClient(uri, options)
clientPromise = client.connect()
}
export default clientPromise
CRUD Operations API
Create pages/api/posts/index.js:
import clientPromise from '../../../lib/mongodb'
export default async function handler(req, res) {
const client = await clientPromise
const db = client.db('blog')
const { method } = req
switch (method) {
case 'GET':
try {
const posts = await db.collection('posts')
.find({})
.sort({ createdAt: -1 })
.limit(10)
.toArray()
res.status(200).json({ posts })
} catch (error) {
res.status(500).json({ error: 'Failed to fetch posts' })
}
break
case 'POST':
try {
const { title, content, author } = req.body
if (!title || !content) {
return res.status(400).json({ error: 'Title and content required' })
}
const post = {
title,
content,
author: author || 'Anonymous',
createdAt: new Date(),
updatedAt: new Date()
}
const result = await db.collection('posts').insertOne(post)
res.status(201).json({
message: 'Post created',
postId: result.insertedId
})
} catch (error) {
res.status(500).json({ error: 'Failed to create post' })
}
break
default:
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
Create pages/api/posts/[id].js:
import { ObjectId } from 'mongodb'
import clientPromise from '../../../lib/mongodb'
export default async function handler(req, res) {
const { id } = req.query
const { method } = req
const client = await clientPromise
const db = client.db('blog')
// Validate ObjectId
if (!ObjectId.isValid(id)) {
return res.status(400).json({ error: 'Invalid post ID' })
}
switch (method) {
case 'GET':
try {
const post = await db.collection('posts')
.findOne({ _id: new ObjectId(id) })
if (!post) {
return res.status(404).json({ error: 'Post not found' })
}
res.status(200).json({ post })
} catch (error) {
res.status(500).json({ error: 'Failed to fetch post' })
}
break
case 'PUT':
try {
const { title, content } = req.body
const updateData = {
...(title && { title }),
...(content && { content }),
updatedAt: new Date()
}
const result = await db.collection('posts')
.updateOne(
{ _id: new ObjectId(id) },
{ $set: updateData }
)
if (result.matchedCount === 0) {
return res.status(404).json({ error: 'Post not found' })
}
res.status(200).json({ message: 'Post updated successfully' })
} catch (error) {
res.status(500).json({ error: 'Failed to update post' })
}
break
case 'DELETE':
try {
const result = await db.collection('posts')
.deleteOne({ _id: new ObjectId(id) })
if (result.deletedCount === 0) {
return res.status(404).json({ error: 'Post not found' })
}
res.status(200).json({ message: 'Post deleted successfully' })
} catch (error) {
res.status(500).json({ error: 'Failed to delete post' })
}
break
default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
Authentication with NextAuth.js
Install NextAuth.js:
npm install next-auth
Create pages/api/auth/[...nextauth].js:
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import CredentialsProvider from 'next-auth/providers/credentials'
export default NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials) {
// Add your authentication logic here
const user = await authenticateUser(credentials)
if (user) {
return {
id: user.id,
name: user.name,
email: user.email,
}
}
return null
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
async session({ session, token }) {
session.user.id = token.id
return session
},
},
pages: {
signIn: '/auth/signin',
signUp: '/auth/signup',
}
})
async function authenticateUser(credentials) {
// Implement your authentication logic
// This is just an example
if (credentials.email === 'user@example.com' && credentials.password === 'password') {
return {
id: 1,
name: 'John Doe',
email: 'user@example.com'
}
}
return null
}
Protected API Routes
Create pages/api/protected/profile.js:
import { getServerSession } from 'next-auth/next'
import { authOptions } from '../auth/[...nextauth]'
export default async function handler(req, res) {
const session = await getServerSession(req, res, authOptions)
if (!session) {
return res.status(401).json({ error: 'Unauthorized' })
}
// User is authenticated
const { method } = req
switch (method) {
case 'GET':
return res.status(200).json({
user: session.user,
message: 'This is protected data'
})
case 'PUT':
// Update user profile
const { name } = req.body
// Update user in database
// const updatedUser = await updateUser(session.user.id, { name })
return res.status(200).json({
message: 'Profile updated',
user: { ...session.user, name }
})
default:
res.setHeader('Allow', ['GET', 'PUT'])
return res.status(405).end(`Method ${method} Not Allowed`)
}
}
Middleware for Request Processing
Create middleware.js in the root directory:
import { NextResponse } from 'next/server'
import { getToken } from 'next-auth/jwt'
export async function middleware(request) {
const { pathname } = request.nextUrl
// Protect API routes
if (pathname.startsWith('/api/protected')) {
const token = await getToken({ req: request })
if (!token) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
}
// Rate limiting example
if (pathname.startsWith('/api/')) {
const ip = request.ip || 'anonymous'
const rateLimitResult = await checkRateLimit(ip)
if (!rateLimitResult.allowed) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
)
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/api/:path*']
}
async function checkRateLimit(ip) {
// Implement rate limiting logic
// This is a simplified example
return { allowed: true }
}
File Upload API
Create pages/api/upload.js:
import formidable from 'formidable'
import fs from 'fs'
import path from 'path'
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const form = formidable({
uploadDir: './public/uploads',
keepExtensions: true,
maxFileSize: 5 * 1024 * 1024, // 5MB
})
try {
const [fields, files] = await form.parse(req)
const file = files.file[0]
if (!file) {
return res.status(400).json({ error: 'No file uploaded' })
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
if (!allowedTypes.includes(file.mimetype)) {
fs.unlinkSync(file.filepath) // Delete uploaded file
return res.status(400).json({ error: 'Invalid file type' })
}
// Generate unique filename
const filename = `${Date.now()}-${file.originalFilename}`
const newPath = path.join('./public/uploads', filename)
// Move file to final location
fs.renameSync(file.filepath, newPath)
res.status(200).json({
message: 'File uploaded successfully',
filename,
url: `/uploads/${filename}`
})
} catch (error) {
console.error('Upload error:', error)
res.status(500).json({ error: 'Upload failed' })
}
}
Error Handling and Validation
Create lib/validation.js:
export function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export function validatePost(data) {
const errors = {}
if (!data.title || data.title.trim().length < 3) {
errors.title = 'Title must be at least 3 characters long'
}
if (!data.content || data.content.trim().length < 10) {
errors.content = 'Content must be at least 10 characters long'
}
if (data.email && !validateEmail(data.email)) {
errors.email = 'Invalid email format'
}
return {
isValid: Object.keys(errors).length === 0,
errors
}
}
Use validation in API route:
import { validatePost } from '../../lib/validation'
export default async function handler(req, res) {
if (req.method === 'POST') {
const validation = validatePost(req.body)
if (!validation.isValid) {
return res.status(400).json({
error: 'Validation failed',
details: validation.errors
})
}
// Proceed with creating the post
// ...
}
}
Testing API Routes
Install testing dependencies:
npm install --save-dev jest @testing-library/jest-dom
Create __tests__/api/posts.test.js:
import handler from '../../pages/api/posts'
import { createMocks } from 'node-mocks-http'
describe('/api/posts', () => {
test('GET returns posts', async () => {
const { req, res } = createMocks({
method: 'GET',
})
await handler(req, res)
expect(res._getStatusCode()).toBe(200)
const data = JSON.parse(res._getData())
expect(data).toHaveProperty('posts')
expect(Array.isArray(data.posts)).toBe(true)
})
test('POST creates a new post', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {
title: 'Test Post',
content: 'This is a test post content',
author: 'Test Author'
},
})
await handler(req, res)
expect(res._getStatusCode()).toBe(201)
const data = JSON.parse(res._getData())
expect(data.message).toBe('Post created')
expect(data).toHaveProperty('postId')
})
})
What's Next?
Congratulations! You now understand:
- ✅ Creating and organizing API routes
- ✅ Handling different HTTP methods
- ✅ Database integration with MongoDB
- ✅ Authentication with NextAuth.js
- ✅ Middleware for request processing
- ✅ File uploads and validation
- ✅ Error handling and testing
In Part 5, we'll cover:
- Styling with CSS Modules and Styled Components
- UI component libraries
- Responsive design
- Performance optimization
- Deployment strategies
Practice Exercises
-
Build a Todo API
- Create CRUD operations for todos
- Add user authentication
- Implement filtering and sorting
-
Create a Blog CMS
- Build admin API routes
- Add image upload functionality
- Implement draft/publish workflow
-
User Management System
- Create user registration/login
- Add role-based permissions
- Implement password reset
Ready for Part 5? Next, we'll make your applications beautiful with advanced styling and UI techniques!
Continue following our comprehensive Next.js series to master full-stack development.