Skip to content

Next.js API Routes

Next.js API routes allow you to create backend API endpoints within the same project without needing a separate server. This chapter will detail how to create and use API routes.

API Routes Basics

App Router API Routes

In Next.js 13+ App Router, API routes use route.ts files:

typescript
// app/api/hello/route.ts
export async function GET() {
  return Response.json({ message: 'Hello, World!' })
}

export async function POST(request: Request) {
  const body = await request.json()
  return Response.json({ received: body })
}

Pages Router API Routes

In the traditional Pages Router:

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

type Data = {
  message: string
}

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  if (req.method === 'GET') {
    res.status(200).json({ message: 'Hello, World!' })
  } else {
    res.setHeader('Allow', ['GET'])
    res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}

HTTP Method Handling

App Router Method Handling

typescript
// app/api/users/route.ts
import { NextRequest } from 'next/server'

// GET request - fetch user list
export async function GET() {
  const users = await fetchUsers()
  return Response.json(users)
}

// POST request - create new user
export async function POST(request: NextRequest) {
  const userData = await request.json()
  const newUser = await createUser(userData)
  return Response.json(newUser, { status: 201 })
}

// PUT request - update user
export async function PUT(request: NextRequest) {
  const userData = await request.json()
  const updatedUser = await updateUser(userData)
  return Response.json(updatedUser)
}

// DELETE request - delete user
export async function DELETE(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const id = searchParams.get('id')
  
  if (!id) {
    return Response.json({ error: 'Missing user ID' }, { status: 400 })
  }
  
  await deleteUser(id)
  return Response.json({ message: 'User deleted' })
}

Pages Router Method Handling

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

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  switch (req.method) {
    case 'GET':
      const users = await fetchUsers()
      res.status(200).json(users)
      break
      
    case 'POST':
      const newUser = await createUser(req.body)
      res.status(201).json(newUser)
      break
      
    case 'PUT':
      const updatedUser = await updateUser(req.body)
      res.status(200).json(updatedUser)
      break
      
    case 'DELETE':
      await deleteUser(req.query.id as string)
      res.status(200).json({ message: 'User deleted' })
      break
      
    default:
      res.setHeader('Allow', ['GET', 'POST', 'PUT', 'DELETE'])
      res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}

Dynamic API Routes

App Router Dynamic Routes

typescript
// app/api/users/[id]/route.ts
import { NextRequest } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const user = await fetchUser(params.id)
  
  if (!user) {
    return Response.json({ error: 'User not found' }, { status: 404 })
  }
  
  return Response.json(user)
}

export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const userData = await request.json()
  const updatedUser = await updateUser(params.id, userData)
  return Response.json(updatedUser)
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  await deleteUser(params.id)
  return Response.json({ message: 'User deleted' })
}

Pages Router Dynamic Routes

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

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query
  
  switch (req.method) {
    case 'GET':
      const user = await fetchUser(id as string)
      if (!user) {
        return res.status(404).json({ error: 'User not found' })
      }
      res.status(200).json(user)
      break
      
    case 'PUT':
      const updatedUser = await updateUser(id as string, req.body)
      res.status(200).json(updatedUser)
      break
      
    case 'DELETE':
      await deleteUser(id as string)
      res.status(200).json({ message: 'User deleted' })
      break
      
    default:
      res.setHeader('Allow', ['GET', 'PUT', 'DELETE'])
      res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}

Request and Response Handling

Handling Request Body

typescript
// app/api/posts/route.ts
export async function POST(request: Request) {
  try {
    // JSON data
    const data = await request.json()
    
    // Form data
    // const formData = await request.formData()
    
    // Text data
    // const text = await request.text()
    
    const post = await createPost(data)
    return Response.json(post, { status: 201 })
  } catch (error) {
    return Response.json(
      { error: 'Invalid request data' },
      { status: 400 }
    )
  }
}

Handling Query Parameters

typescript
// app/api/search/route.ts
import { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const query = searchParams.get('q')
  const page = searchParams.get('page') || '1'
  const limit = searchParams.get('limit') || '10'
  
  if (!query) {
    return Response.json(
      { error: 'Missing query parameter' },
      { status: 400 }
    )
  }
  
  const results = await searchPosts({
    query,
    page: parseInt(page),
    limit: parseInt(limit)
  })
  
  return Response.json(results)
}

Setting Response Headers

typescript
// app/api/data/route.ts
export async function GET() {
  const data = await fetchData()
  
  return Response.json(data, {
    status: 200,
    headers: {
      'Cache-Control': 'public, max-age=3600',
      'Content-Type': 'application/json',
      'X-Custom-Header': 'custom-value'
    }
  })
}

Middleware and Authentication

Authentication Middleware

typescript
// lib/auth.ts
import jwt from 'jsonwebtoken'

export function verifyToken(token: string) {
  try {
    return jwt.verify(token, process.env.JWT_SECRET!)
  } catch (error) {
    return null
  }
}

export function extractToken(request: Request): string | null {
  const authHeader = request.headers.get('authorization')
  if (authHeader && authHeader.startsWith('Bearer ')) {
    return authHeader.substring(7)
  }
  return null
}

Protected API Routes

typescript
// app/api/protected/route.ts
import { NextRequest } from 'next/server'
import { verifyToken, extractToken } from '@/lib/auth'

export async function GET(request: NextRequest) {
  const token = extractToken(request)
  
  if (!token) {
    return Response.json(
      { error: 'Missing authentication token' },
      { status: 401 }
    )
  }
  
  const user = verifyToken(token)
  
  if (!user) {
    return Response.json(
      { error: 'Invalid token' },
      { status: 401 }
    )
  }
  
  // Return protected data
  const data = await fetchProtectedData(user.id)
  return Response.json(data)
}

File Upload

Handling File Uploads

typescript
// app/api/upload/route.ts
import { NextRequest } from 'next/server'
import { writeFile } from 'fs/promises'
import path from 'path'

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData()
    const file = formData.get('file') as File
    
    if (!file) {
      return Response.json(
        { error: 'No file found' },
        { status: 400 }
      )
    }
    
    // Validate file type
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
    if (!allowedTypes.includes(file.type)) {
      return Response.json(
        { error: 'Unsupported file type' },
        { status: 400 }
      )
    }
    
    // Validate file size (5MB)
    if (file.size > 5 * 1024 * 1024) {
      return Response.json(
        { error: 'File too large' },
        { status: 400 }
      )
    }
    
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)
    
    // Generate unique filename
    const filename = `${Date.now()}-${file.name}`
    const filepath = path.join(process.cwd(), 'public/uploads', filename)
    
    await writeFile(filepath, buffer)
    
    return Response.json({
      message: 'File uploaded successfully',
      filename,
      url: `/uploads/${filename}`
    })
  } catch (error) {
    return Response.json(
      { error: 'File upload failed' },
      { status: 500 }
    )
  }
}

Database Integration

Using Prisma

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

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

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

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
typescript
// app/api/users/route.ts
import { prisma } from '@/lib/prisma'

export async function GET() {
  try {
    const users = await prisma.user.findMany({
      select: {
        id: true,
        name: true,
        email: true,
        createdAt: true
      }
    })
    return Response.json(users)
  } catch (error) {
    return Response.json(
      { error: 'Failed to fetch users' },
      { status: 500 }
    )
  }
}

export async function POST(request: Request) {
  try {
    const { name, email } = await request.json()
    
    const user = await prisma.user.create({
      data: { name, email }
    })
    
    return Response.json(user, { status: 201 })
  } catch (error) {
    return Response.json(
      { error: 'Failed to create user' },
      { status: 500 }
    )
  }
}

Error Handling

Unified Error Handling

typescript
// lib/api-error.ts
export class ApiError extends Error {
  constructor(
    public message: string,
    public statusCode: number = 500
  ) {
    super(message)
    this.name = 'ApiError'
  }
}

export function handleApiError(error: unknown) {
  if (error instanceof ApiError) {
    return Response.json(
      { error: error.message },
      { status: error.statusCode }
    )
  }
  
  console.error('API Error:', error)
  return Response.json(
    { error: 'Internal server error' },
    { status: 500 }
  )
}
typescript
// app/api/users/[id]/route.ts
import { ApiError, handleApiError } from '@/lib/api-error'

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const user = await prisma.user.findUnique({
      where: { id: params.id }
    })
    
    if (!user) {
      throw new ApiError('User not found', 404)
    }
    
    return Response.json(user)
  } catch (error) {
    return handleApiError(error)
  }
}

Data Validation

Using Zod Validation

typescript
// lib/validations.ts
import { z } from 'zod'

export const createUserSchema = z.object({
  name: z.string().min(1, 'Name cannot be empty').max(50, 'Name too long'),
  email: z.string().email('Invalid email format'),
  age: z.number().min(0, 'Age cannot be negative').max(120, 'Invalid age')
})

export type CreateUserInput = z.infer<typeof createUserSchema>
typescript
// app/api/users/route.ts
import { createUserSchema } from '@/lib/validations'

export async function POST(request: Request) {
  try {
    const body = await request.json()
    const validatedData = createUserSchema.parse(body)
    
    const user = await prisma.user.create({
      data: validatedData
    })
    
    return Response.json(user, { status: 201 })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return Response.json(
        { error: 'Data validation failed', details: error.errors },
        { status: 400 }
      )
    }
    
    return handleApiError(error)
  }
}

Caching Strategies

Response Caching

typescript
// app/api/posts/route.ts
export async function GET() {
  const posts = await fetchPosts()
  
  return Response.json(posts, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300'
    }
  })
}

Using Next.js Cache

typescript
// app/api/data/route.ts
import { unstable_cache } from 'next/cache'

const getCachedData = unstable_cache(
  async () => {
    return await fetchExpensiveData()
  },
  ['expensive-data'],
  { revalidate: 3600 } // 1 hour
)

export async function GET() {
  const data = await getCachedData()
  return Response.json(data)
}

Testing API Routes

Unit Testing

typescript
// __tests__/api/users.test.ts
import { GET, POST } from '@/app/api/users/route'
import { NextRequest } from 'next/server'

describe('/api/users', () => {
  it('should return users list', async () => {
    const response = await GET()
    const data = await response.json()
    
    expect(response.status).toBe(200)
    expect(Array.isArray(data)).toBe(true)
  })
  
  it('should create a new user', async () => {
    const userData = {
      name: 'Test User',
      email: 'test@example.com'
    }
    
    const request = new NextRequest('http://localhost:3000/api/users', {
      method: 'POST',
      body: JSON.stringify(userData),
      headers: { 'Content-Type': 'application/json' }
    })
    
    const response = await POST(request)
    const data = await response.json()
    
    expect(response.status).toBe(201)
    expect(data.name).toBe(userData.name)
    expect(data.email).toBe(userData.email)
  })
})

Best Practices

1. Use TypeScript

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

interface ApiResponse<T> {
  data?: T
  error?: string
  message?: string
}

2. Unified Response Format

typescript
// lib/api-response.ts
export function successResponse<T>(data: T, message?: string) {
  return Response.json({
    success: true,
    data,
    message
  })
}

export function errorResponse(error: string, statusCode: number = 400) {
  return Response.json({
    success: false,
    error
  }, { status: statusCode })
}

3. Environment Variable Management

typescript
// lib/env.ts
export const env = {
  DATABASE_URL: process.env.DATABASE_URL!,
  JWT_SECRET: process.env.JWT_SECRET!,
  API_KEY: process.env.API_KEY!,
}

// Validate required environment variables
Object.entries(env).forEach(([key, value]) => {
  if (!value) {
    throw new Error(`Missing required environment variable: ${key}`)
  }
})

Summary

Next.js API routes provide powerful backend capabilities:

  • Easy to use - File system based routing
  • Full-stack development - Frontend and backend in the same project
  • Type safety - Full TypeScript support
  • Performance optimization - Built-in caching and optimization
  • Simple deployment - Deploy together with frontend

By using API routes effectively, you can build complete full-stack applications without needing a separate backend server.

Content is for learning and research only.