Skip to main content

Chapter 8: API Routes and Backend Functionality

What are API Routes in Next.js?

API Routes allow you to create backend functionality within your Next.js application. They are serverless functions that handle HTTP requests and can be used to build REST APIs, handle form submissions, integrate with databases, and more.

Key Features:

  • Serverless Functions: Each API route is a serverless function
  • Built-in Routing: File-based API routing in pages/api/
  • Request/Response Handling: Full access to request and response objects
  • Middleware Support: Built-in middleware for authentication, CORS, etc.
  • TypeScript Support: Full TypeScript support out of the box
  • Edge Runtime: Optional edge runtime for better performance

Why Use API Routes?

Benefits:

  • Full-Stack Development: Build frontend and backend in one project
  • Serverless: Automatic scaling and deployment
  • No Server Management: No need to manage separate backend servers
  • Type Safety: Share types between frontend and backend
  • Deployment Simplicity: Deploy everything together
  • Cost Effective: Pay only for what you use

How to Create API Routes

1. Basic API Route Structure

Simple GET Endpoint:

// pages/api/hello.js
export default function handler(req, res) {
res.status(200).json({ message: 'Hello from Next.js API!' })
}

HTTP Method Handling:

// pages/api/users.js
export default function handler(req, res) {
const { method } = req

switch (method) {
case 'GET':
return handleGet(req, res)
case 'POST':
return handlePost(req, res)
case 'PUT':
return handlePut(req, res)
case 'DELETE':
return handleDelete(req, res)
default:
res.setHeader('Allow', ['GET', 'POST', 'PUT', 'DELETE'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}

async function handleGet(req, res) {
try {
const users = await fetchUsers()
res.status(200).json(users)
} catch (error) {
res.status(500).json({ error: 'Failed to fetch users' })
}
}

async function handlePost(req, res) {
try {
const user = await createUser(req.body)
res.status(201).json(user)
} catch (error) {
res.status(400).json({ error: 'Failed to create user' })
}
}

async function handlePut(req, res) {
try {
const user = await updateUser(req.body)
res.status(200).json(user)
} catch (error) {
res.status(400).json({ error: 'Failed to update user' })
}
}

async function handleDelete(req, res) {
try {
await deleteUser(req.body.id)
res.status(204).end()
} catch (error) {
res.status(400).json({ error: 'Failed to delete user' })
}
}

2. Dynamic API Routes

Single Dynamic Parameter:

// pages/api/users/[id].js
export default async function handler(req, res) {
const { id } = req.query
const { method } = req

switch (method) {
case 'GET':
try {
const user = await getUserById(id)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
res.status(200).json(user)
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user' })
}
break

case 'PUT':
try {
const updatedUser = await updateUser(id, req.body)
res.status(200).json(updatedUser)
} catch (error) {
res.status(400).json({ error: 'Failed to update user' })
}
break

case 'DELETE':
try {
await deleteUser(id)
res.status(204).end()
} catch (error) {
res.status(400).json({ error: 'Failed to delete user' })
}
break

default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}

Multiple Dynamic Parameters:

// pages/api/posts/[userId]/[postId].js
export default async function handler(req, res) {
const { userId, postId } = req.query
const { method } = req

switch (method) {
case 'GET':
try {
const post = await getPostByUserAndId(userId, postId)
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

default:
res.setHeader('Allow', ['GET'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}

Catch-all Routes:

// pages/api/docs/[...slug].js
export default function handler(req, res) {
const { slug } = req.query
const path = slug.join('/')

// Handle different documentation paths
switch (path) {
case 'api/users':
return res.status(200).json({ docs: 'User API documentation' })
case 'api/posts':
return res.status(200).json({ docs: 'Post API documentation' })
default:
return res.status(404).json({ error: 'Documentation not found' })
}
}

3. Request and Response Handling

Reading Request Data:

// pages/api/contact.js
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}

try {
const { name, email, message } = req.body

// Validate required fields
if (!name || !email || !message) {
return res.status(400).json({
error: 'Missing required fields',
required: ['name', 'email', 'message']
})
}

// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Invalid email format' })
}

// Process the contact form
await sendContactEmail({ name, email, message })

res.status(200).json({
message: 'Contact form submitted successfully',
id: generateId()
})
} catch (error) {
console.error('Contact form error:', error)
res.status(500).json({ error: 'Failed to submit contact form' })
}
}

Setting Response Headers:

// pages/api/data.js
export default function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')

// Set cache headers
res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300')

// Set content type
res.setHeader('Content-Type', 'application/json')

const data = { message: 'Hello from API' }
res.status(200).json(data)
}

File Upload Handling:

// pages/api/upload.js
import formidable from 'formidable'
import fs from 'fs'

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' })
}

try {
const form = formidable({
uploadDir: './public/uploads',
keepExtensions: true,
})

const [fields, files] = await form.parse(req)

const file = files.file[0]
const { name, email } = fields

// Process the uploaded file
const fileInfo = {
originalName: file.originalFilename,
fileName: file.newFilename,
size: file.size,
type: file.mimetype,
uploadedBy: { name: name[0], email: email[0] },
uploadedAt: new Date().toISOString(),
}

res.status(200).json({
message: 'File uploaded successfully',
file: fileInfo
})
} catch (error) {
console.error('Upload error:', error)
res.status(500).json({ error: 'Failed to upload file' })
}
}

4. Authentication and Authorization

JWT Authentication:

// pages/api/auth/login.js
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'

export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}

try {
const { email, password } = req.body

// Find user in database
const user = await findUserByEmail(email)
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' })
}

// Verify password
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' })
}

// Generate JWT token
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
)

// Set HTTP-only cookie
res.setHeader('Set-Cookie', [
`token=${token}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Strict`
])

res.status(200).json({
message: 'Login successful',
user: {
id: user.id,
email: user.email,
name: user.name
}
})
} catch (error) {
console.error('Login error:', error)
res.status(500).json({ error: 'Login failed' })
}
}

Protected Route Middleware:

// lib/auth.js
import jwt from 'jsonwebtoken'

export function verifyToken(req) {
const token = req.headers.authorization?.replace('Bearer ', '') ||
req.cookies.token

if (!token) {
throw new Error('No token provided')
}

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
return decoded
} catch (error) {
throw new Error('Invalid token')
}
}

// pages/api/protected.js
import { verifyToken } from '../../lib/auth'

export default async function handler(req, res) {
try {
// Verify authentication
const user = verifyToken(req)

// Handle the protected route
const data = await getProtectedData(user.userId)

res.status(200).json({ data, user })
} catch (error) {
res.status(401).json({ error: error.message })
}
}

5. Database Integration

MongoDB Integration:

// lib/mongodb.js
import { MongoClient } from 'mongodb'

const client = new MongoClient(process.env.MONGODB_URI)
let cachedClient = null

export async function connectToDatabase() {
if (cachedClient) {
return cachedClient
}

try {
await client.connect()
cachedClient = client
return client
} catch (error) {
console.error('MongoDB connection error:', error)
throw error
}
}

// pages/api/posts.js
import { connectToDatabase } from '../../lib/mongodb'

export default async function handler(req, res) {
const { method } = req

try {
const { db } = await connectToDatabase()

switch (method) {
case 'GET':
const posts = await db.collection('posts').find({}).toArray()
res.status(200).json(posts)
break

case 'POST':
const { title, content, author } = req.body
const result = await db.collection('posts').insertOne({
title,
content,
author,
createdAt: new Date(),
})
res.status(201).json({ id: result.insertedId })
break

default:
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).end(`Method ${method} Not Allowed`)
}
} catch (error) {
console.error('Database error:', error)
res.status(500).json({ error: 'Database operation failed' })
}
}

Prisma Integration:

// lib/prisma.js
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis

export const prisma = globalForPrisma.prisma || new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

// pages/api/users.js
import { prisma } from '../../lib/prisma'

export default async function handler(req, res) {
const { method } = req

try {
switch (method) {
case 'GET':
const users = await prisma.user.findMany({
include: { posts: true }
})
res.status(200).json(users)
break

case 'POST':
const { name, email } = req.body
const user = await prisma.user.create({
data: { name, email }
})
res.status(201).json(user)
break

default:
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).end(`Method ${method} Not Allowed`)
}
} catch (error) {
console.error('Prisma error:', error)
res.status(500).json({ error: 'Database operation failed' })
}
}

6. External API Integration

Third-party API Integration:

// pages/api/weather.js
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' })
}

try {
const { city } = req.query

if (!city) {
return res.status(400).json({ error: 'City parameter is required' })
}

const response = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${process.env.OPENWEATHER_API_KEY}&units=metric`
)

if (!response.ok) {
throw new Error('Weather API request failed')
}

const weatherData = await response.json()

// Transform the data
const transformedData = {
city: weatherData.name,
country: weatherData.sys.country,
temperature: weatherData.main.temp,
description: weatherData.weather[0].description,
humidity: weatherData.main.humidity,
windSpeed: weatherData.wind.speed,
}

res.status(200).json(transformedData)
} catch (error) {
console.error('Weather API error:', error)
res.status(500).json({ error: 'Failed to fetch weather data' })
}
}

Webhook Handling:

// pages/api/webhooks/stripe.js
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}

try {
const sig = req.headers['stripe-signature']
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET

let event

try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret)
} catch (err) {
console.error('Webhook signature verification failed:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}

// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object
await handleSuccessfulPayment(paymentIntent)
break

case 'payment_intent.payment_failed':
const failedPayment = event.data.object
await handleFailedPayment(failedPayment)
break

default:
console.log(`Unhandled event type ${event.type}`)
}

res.status(200).json({ received: true })
} catch (error) {
console.error('Webhook error:', error)
res.status(500).json({ error: 'Webhook processing failed' })
}
}

Advanced API Route Patterns

1. API Route Middleware

// lib/apiMiddleware.js
export function withAuth(handler) {
return async (req, res) => {
try {
const user = verifyToken(req)
req.user = user
return handler(req, res)
} catch (error) {
return res.status(401).json({ error: 'Unauthorized' })
}
}
}

export function withValidation(schema) {
return (handler) => {
return async (req, res) => {
try {
await schema.validate(req.body)
return handler(req, res)
} catch (error) {
return res.status(400).json({ error: error.message })
}
}
}
}

// pages/api/protected.js
import { withAuth } from '../../lib/apiMiddleware'

async function handler(req, res) {
res.status(200).json({ message: 'Protected data', user: req.user })
}

export default withAuth(handler)

2. API Route Composition

// lib/apiComposition.js
export function compose(...middlewares) {
return middlewares.reduce((a, b) => (req, res) => a(req, res, () => b(req, res)))
}

// pages/api/complex.js
import { compose, withAuth, withValidation, withRateLimit } from '../../lib/apiComposition'
import { userSchema } from '../../lib/schemas'

async function handler(req, res) {
// Your main logic here
res.status(200).json({ success: true })
}

export default compose(
withRateLimit(100), // 100 requests per hour
withAuth,
withValidation(userSchema)
)(handler)

Best Practices

1. Error Handling

// ✅ Good: Comprehensive error handling
export default async function handler(req, res) {
try {
// Your logic here
const result = await processRequest(req)
res.status(200).json(result)
} catch (error) {
console.error('API Error:', error)

if (error.name === 'ValidationError') {
return res.status(400).json({ error: error.message })
}

if (error.name === 'UnauthorizedError') {
return res.status(401).json({ error: 'Unauthorized' })
}

res.status(500).json({ error: 'Internal server error' })
}
}

2. Input Validation

// ✅ Good: Validate all inputs
import Joi from 'joi'

const userSchema = Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
age: Joi.number().min(18).max(120).optional(),
})

export default async function handler(req, res) {
const { error, value } = userSchema.validate(req.body)

if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details
})
}

// Use validated data
const { name, email, age } = value
}

3. Rate Limiting

// lib/rateLimit.js
const rateLimitMap = new Map()

export function withRateLimit(limit = 100, windowMs = 60 * 60 * 1000) {
return (handler) => {
return async (req, res) => {
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress
const now = Date.now()
const windowStart = now - windowMs

if (!rateLimitMap.has(ip)) {
rateLimitMap.set(ip, [])
}

const requests = rateLimitMap.get(ip)
const validRequests = requests.filter(time => time > windowStart)

if (validRequests.length >= limit) {
return res.status(429).json({ error: 'Too many requests' })
}

validRequests.push(now)
rateLimitMap.set(ip, validRequests)

return handler(req, res)
}
}
}

Common Mistakes to Avoid

1. Not Handling CORS

// ❌ Wrong: No CORS handling
export default function handler(req, res) {
res.status(200).json({ data: 'test' })
}

// ✅ Correct: Proper CORS handling
export default function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')

if (req.method === 'OPTIONS') {
res.status(200).end()
return
}

res.status(200).json({ data: 'test' })
}

2. Exposing Sensitive Data

// ❌ Wrong: Exposing sensitive data
export default function handler(req, res) {
const user = await getUser(req.query.id)
res.status(200).json(user) // Includes password hash
}

// ✅ Correct: Filter sensitive data
export default function handler(req, res) {
const user = await getUser(req.query.id)
const { password, ...safeUser } = user
res.status(200).json(safeUser)
}

3. Not Validating Input

// ❌ Wrong: No input validation
export default function handler(req, res) {
const { email } = req.body
const user = await createUser({ email }) // Could be invalid
}

// ✅ Correct: Validate input
export default function handler(req, res) {
const { email } = req.body

if (!email || !isValidEmail(email)) {
return res.status(400).json({ error: 'Invalid email' })
}

const user = await createUser({ email })
}

Summary

API Routes in Next.js provide a powerful way to build backend functionality:

  • Serverless functions for scalable backend logic
  • File-based routing for intuitive API organization
  • Full request/response control for custom behavior
  • Database integration with popular ORMs
  • Authentication and authorization patterns
  • External API integration capabilities

Key Takeaways:

  • Use appropriate HTTP methods for different operations
  • Implement proper error handling and validation
  • Secure your APIs with authentication and rate limiting
  • Follow RESTful conventions for API design
  • Optimize for performance with caching and database queries

This tutorial is part of the comprehensive Next.js learning path at syscook.dev. Continue to the next chapter to learn about advanced topics and optimization.

Author: syscook.dev
Last Updated: December 2024