Next.js Complete Guide Part 4: API Routes and Full-Stack Development

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.

By Next.js Expert
10 min read
1879 words

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

  1. Build a Todo API

    • Create CRUD operations for todos
    • Add user authentication
    • Implement filtering and sorting
  2. Create a Blog CMS

    • Build admin API routes
    • Add image upload functionality
    • Implement draft/publish workflow
  3. 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.

Share this article: