⚡ TECH BLOG
Home
Blog
Tags
About
⚡

Powered by Next.js 15 & Modern Web Tech ⚡

Back to Home

Next.js API Routes: A Deep Dive

October 15, 2022
nextjsapinodejsfullstack
Next.js API Routes: A Deep Dive

Next.js API Routes: A Deep Dive

Next.js API Routes enable you to build API endpoints within your Next.js application. This powerful feature allows you to create full-stack applications without a separate backend server.

Introduction to API Routes

Creating Your First API Route

// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'

type ResponseData = {
  message: string
}

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseData>
) {
  res.status(200).json({ message: 'Hello from Next.js!' })
}

Dynamic API Routes

// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'

interface User {
  id: string
  name: string
  email: string
}

const users: User[] = [
  { id: '1', name: 'John Doe', email: 'john@example.com' },
  { id: '2', name: 'Jane Doe', email: 'jane@example.com' },
]

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<User | { error: string }>
) {
  const { id } = req.query

  if (req.method === 'GET') {
    const user = users.find(u => u.id === id)
    
    if (!user) {
      return res.status(404).json({ error: 'User not found' })
    }
    
    return res.status(200).json(user)
  }

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

Catch-all Routes

// pages/api/posts/[...slug].ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const { slug } = req.query
  // slug is an array of path segments
  // /api/posts/2022/10/my-post -> slug: ['2022', '10', 'my-post']
  
  res.status(200).json({ slug })
}

HTTP Methods

RESTful API Pattern

// pages/api/posts/index.ts
import type { NextApiRequest, NextApiResponse } from 'next'

interface Post {
  id: string
  title: string
  content: string
}

let posts: Post[] = [
  { id: '1', title: 'First Post', content: 'Hello World' },
]

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Post | Post[] | { error: string }>
) {
  switch (req.method) {
    case 'GET':
      return res.status(200).json(posts)
    
    case 'POST': {
      const { title, content } = req.body
      
      if (!title || !content) {
        return res.status(400).json({ error: 'Missing required fields' })
      }
      
      const newPost: Post = {
        id: Date.now().toString(),
        title,
        content,
      }
      
      posts.push(newPost)
      return res.status(201).json(newPost)
    }
    
    default:
      res.setHeader('Allow', ['GET', 'POST'])
      return res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}

Handling PUT and DELETE

// pages/api/posts/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'

interface Post {
  id: string
  title: string
  content: string
}

let posts: Post[] = [
  { id: '1', title: 'First Post', content: 'Hello World' },
]

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Post | { error: string }>
) {
  const { id } = req.query
  const postIndex = posts.findIndex(p => p.id === id)

  if (postIndex === -1) {
    return res.status(404).json({ error: 'Post not found' })
  }

  switch (req.method) {
    case 'GET':
      return res.status(200).json(posts[postIndex])
    
    case 'PUT': {
      const { title, content } = req.body
      posts[postIndex] = {
        ...posts[postIndex],
        ...(title && { title }),
        ...(content && { content }),
      }
      return res.status(200).json(posts[postIndex])
    }
    
    case 'DELETE':
      posts = posts.filter(p => p.id !== id)
      return res.status(204).end()
    
    default:
      res.setHeader('Allow', ['GET', 'PUT', 'DELETE'])
      return res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}

Request Handling

Parsing Request Body

// Next.js automatically parses JSON bodies
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'POST') {
    // For JSON content-type, body is already parsed
    const { name, email } = req.body
    
    console.log('Parsed body:', { name, email })
    
    res.status(200).json({ received: req.body })
  }
}

Handling File Uploads

// pages/api/upload.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import formidable from 'formidable'
import fs from 'fs'
import path from 'path'

export const config = {
  api: {
    bodyParser: false, // Disable default body parser for file uploads
  },
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const form = formidable({
    uploadDir: './public/uploads',
    keepExtensions: true,
  })

  try {
    const [fields, files] = await form.parse(req)
    
    res.status(200).json({
      fields,
      files,
    })
  } catch (error) {
    res.status(500).json({ error: 'Error uploading file' })
  }
}

Cookies and Headers

// pages/api/auth/login.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { serialize } from 'cookie'

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'POST') {
    const { email, password } = req.body
    
    // Validate credentials
    if (email === 'admin@example.com' && password === 'password') {
      // Set cookie
      res.setHeader('Set-Cookie', serialize('token', 'jwt-token-here', {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 60 * 60 * 24 * 7, // 1 week
        path: '/',
      }))
      
      return res.status(200).json({ success: true })
    }
    
    return res.status(401).json({ error: 'Invalid credentials' })
  }
}

Middleware

Custom Middleware

// pages/api/_middleware.ts
import type { NextApiRequest, NextApiResponse } from 'next'

type MiddlewareFunction = (
  req: NextApiRequest,
  res: NextApiResponse,
  next: () => void
) => void

export function runMiddleware(
  req: NextApiRequest,
  res: NextApiResponse,
  middleware: MiddlewareFunction
): Promise<void> {
  return new Promise((resolve, reject) => {
    middleware(req, res, (result?: Error) => {
      if (result instanceof Error) {
        return reject(result)
      }
      return resolve()
    })
  })
}

// Usage
import Cors from 'cors'

const cors = Cors({
  origin: '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
})

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  await runMiddleware(req, res, cors)
  
  res.status(200).json({ message: 'CORS enabled' })
}

Authentication Middleware

// lib/auth-middleware.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { verify } from 'jsonwebtoken'

export function authenticate(
  req: NextApiRequest,
  res: NextApiResponse
): string | null {
  const token = req.cookies.token
  
  if (!token) {
    res.status(401).json({ error: 'Not authenticated' })
    return null
  }
  
  try {
    const decoded = verify(token, process.env.JWT_SECRET!)
    return (decoded as { userId: string }).userId
  } catch {
    res.status(401).json({ error: 'Invalid token' })
    return null
  }
}

// Usage
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const userId = authenticate(req, res)
  if (!userId) return // Response already sent
  
  // Continue with authenticated request
  res.status(200).json({ userId })
}

Database Integration

Prisma Integration

// pages/api/users/index.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  switch (req.method) {
    case 'GET': {
      const users = await prisma.user.findMany()
      return res.status(200).json(users)
    }
    
    case 'POST': {
      const { name, email } = req.body
      
      const user = await prisma.user.create({
        data: { name, email },
      })
      
      return res.status(201).json(user)
    }
    
    default:
      res.setHeader('Allow', ['GET', 'POST'])
      return res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}

Connection Pooling

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

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  })

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

Error Handling

Centralized Error Handling

// lib/api-utils.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export class ApiError extends Error {
  statusCode: number
  
  constructor(statusCode: number, message: string) {
    super(message)
    this.statusCode = statusCode
  }
}

export function handleErrors(
  handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void>
) {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    try {
      await handler(req, res)
    } catch (error) {
      console.error('API Error:', error)
      
      if (error instanceof ApiError) {
        return res.status(error.statusCode).json({ error: error.message })
      }
      
      return res.status(500).json({ error: 'Internal Server Error' })
    }
  }
}

// Usage
export default handleErrors(async (req, res) => {
  if (req.method !== 'GET') {
    throw new ApiError(405, 'Method Not Allowed')
  }
  
  const users = await fetchUsers()
  res.status(200).json(users)
})

Response Helpers

// lib/api-response.ts
import type { NextApiResponse } from 'next'

export function success<T>(res: NextApiResponse, data: T, status = 200) {
  return res.status(status).json(data)
}

export function created<T>(res: NextApiResponse, data: T) {
  return res.status(201).json(data)
}

export function noContent(res: NextApiResponse) {
  return res.status(204).end()
}

export function badRequest(res: NextApiResponse, message: string) {
  return res.status(400).json({ error: message })
}

export function unauthorized(res: NextApiResponse, message = 'Unauthorized') {
  return res.status(401).json({ error: message })
}

export function notFound(res: NextApiResponse, message = 'Not Found') {
  return res.status(404).json({ error: message })
}

Rate Limiting

// pages/api/_rate-limit.ts
import type { NextApiRequest, NextApiResponse } from 'next'

const rateLimit = new Map<string, { count: number; resetTime: number }>()

export function checkRateLimit(
  req: NextApiRequest,
  res: NextApiResponse,
  limit = 10,
  windowMs = 60000
): boolean {
  const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown'
  const now = Date.now()
  
  const record = rateLimit.get(ip as string)
  
  if (!record || now > record.resetTime) {
    rateLimit.set(ip as string, { count: 1, resetTime: now + windowMs })
    return true
  }
  
  if (record.count >= limit) {
    res.status(429).json({ error: 'Too many requests' })
    return false
  }
  
  record.count++
  return true
}

// Usage
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (!checkRateLimit(req, res)) return
  
  res.status(200).json({ message: 'Success' })
}

Testing API Routes

Unit Testing with Jest

// __tests__/api/users.test.ts
import handler from '@/pages/api/users/index'
import { createMocks } from 'node-mocks-http'

describe('/api/users', () => {
  it('returns all users on GET', async () => {
    const { req, res } = createMocks({
      method: 'GET',
    })
    
    await handler(req, res)
    
    expect(res._getStatusCode()).toBe(200)
    expect(JSON.parse(res._getData())).toBeInstanceOf(Array)
  })
  
  it('creates a user on POST', async () => {
    const { req, res } = createMocks({
      method: 'POST',
      body: {
        name: 'Test User',
        email: 'test@example.com',
      },
    })
    
    await handler(req, res)
    
    expect(res._getStatusCode()).toBe(201)
    const data = JSON.parse(res._getData())
    expect(data.name).toBe('Test User')
  })
})

Best Practices

1. Input Validation

import { z } from 'zod'

const userSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
})

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'POST') {
    try {
      const validated = userSchema.parse(req.body)
      // Continue with validated data
    } catch (error) {
      return res.status(400).json({ error: 'Invalid input', details: error })
    }
  }
}

2. Environment Variables

// Never expose secrets to client
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const apiKey = process.env.API_KEY // Server-side only
  
  if (!apiKey) {
    console.error('API_KEY not configured')
    return res.status(500).json({ error: 'Server configuration error' })
  }
}

3. Structured Logging

function logRequest(req: NextApiRequest, statusCode: number, duration: number) {
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    method: req.method,
    path: req.url,
    statusCode,
    duration: `${duration}ms`,
  }))
}

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const start = Date.now()
  
  // ... handler logic ...
  
  logRequest(req, 200, Date.now() - start)
}

Conclusion

Next.js API Routes provide a powerful way to build full-stack applications. By following these patterns and best practices, you can create robust, scalable APIs within your Next.js application.

Key Takeaways

  • Use proper HTTP methods and status codes
  • Implement middleware for cross-cutting concerns
  • Validate all inputs
  • Handle errors gracefully
  • Implement rate limiting for public APIs

Start building your APIs with Next.js and enjoy the simplicity of a unified full-stack framework!

Share:

💬 Comments