Next.js API Routes and Route Handlers: Complete Guide to Serverless Endpoints

One of the greatest features of Next.js is its ability to seamlessly bridge the gap between frontend UI and backend server logic. Rather than deploying a separate Node.js or Express backend, Next.js allows developers to build lightweight, full-stack serverless REST API endpoints directly within the same codebase using API Routes and Route Handlers.

In this comprehensive guide, we will explore how to build, secure, and optimize backend endpoints in Next.js. We will cover both the classic Page Router (/pages/api) and the modern App Router (/app/api) conventions, handling dynamic routes, processing requests, error handling, and security best practices.


1. Understanding Next.js Serverless Architecture

Before writing code, it is essential to understand how Next.js executes backend logic. When you deploy a Next.js application to platforms like Vercel or AWS Amplify, each API endpoint is automatically converted into an isolated, auto-scaling serverless function.

Serverless architecture brings distinct advantages:

  • Zero Server Maintenance: No need to manage server instances, ports, or process managers like PM2.
  • Automatic Scaling: Each API endpoint scales independently from zero to thousands of concurrent executions based on traffic demands.
  • Unified Development Experience: Share TypeScript interfaces, validation schemas, and utility functions between frontend UI components and backend routes seamlessly.

2. Legacy Standard: Pages Router API Routes (pages/api)

In applications using the Pages Router directory, any file placed inside pages/api/ is mapped to /api/* and treated as an API endpoint instead of an HTML page. These endpoints are Node.js request listeners that accept standard NextApiRequest and NextApiResponse objects.

Basic CRUD Handler Example

Here is how a standard HTTP handler is written using the Pages Router:

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

type User = {
  id: number;
  name: string;
};

type Data = {
  success: boolean;
  data?: User[] | User;
  error?: string;
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  const { method } = req;

  switch (method) {
    case 'GET':
      // Handle GET request (e.g., fetch users from database)
      return res.status(200).json({
        success: true,
        data: [{ id: 1, name: 'Alex Johnson' }]
      });

    case 'POST':
      // Handle POST request (e.g., create a new user)
      const { name } = req.body;
      if (!name) {
        return res.status(400).json({ success: false, error: 'Name is required' });
      }
      return res.status(201).json({
        success: true,
        data: { id: Date.now(), name }
      });

    default:
      res.setHeader('Allow', ['GET', 'POST']);
      return res.status(405).json({
        success: false,
        error: `Method ${method} Not Allowed`
      });
  }
}

3. Modern Standard: App Router Route Handlers (app/api)

Next.js introduced Route Handlers inside the App Router directory using route.ts files. Route Handlers are built on top of standard Web API Request and Response interfaces, bringing native compatibility with modern Edge Runtimes and web standards.

Explicit Method Exports

Instead of a single default export with a switch statement, Route Handlers export individual functions named after HTTP verbs (GET, POST, PUT, DELETE, PATCH):

// app/api/users/route.ts
import { NextResponse } from 'next/server';

// Handle GET /api/users
export async function GET() {
  const users = [
    { id: 1, name: 'Alex Johnson' },
    { id: 2, name: 'Sarah Connor' }
  ];

  return NextResponse.json({ success: true, data: users }, { status: 200 });
}

// Handle POST /api/users
export async function POST(request: Request) {
  try {
    const body = await request.json();

    if (!body.name) {
      return NextResponse.json(
        { success: false, error: 'Name is required' },
        { status: 400 }
      );
    }

    const newUser = { id: Date.now(), name: body.name };
    return NextResponse.json({ success: true, data: newUser }, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { success: false, error: 'Invalid payload' },
      { status: 500 }
    );
  }
}

4. Dynamic Routes and Path Parameters

Just like dynamic pages, Next.js supports dynamic URL parameters in API routes for fetching or updating single resources by ID.

Pages Router Dynamic Handler (pages/api/users/[id].ts)

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

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const { id } = req.query; // Extracted directly from URL string

  if (req.method === 'GET') {
    return res.status(200).json({ id, name: `User ${id}` });
  }

  return res.status(405).end();
}

App Router Dynamic Handler (app/api/users/[id]/route.ts)

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

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params; // Parameters resolved asynchronously

  return NextResponse.json({ id, name: `User ${id}` }, { status: 200 });
}

5. Middleware, Auth & Production Best Practices

A. Request Body Validation with Zod

Never trust unvalidated client input. Use validation libraries like Zod to parse and validate JSON payloads before executing database operations:

import { NextResponse } from 'next/server';
import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string().email(),
  age: z.number().min(18)
});

export async function POST(request: Request) {
  const body = await request.json();
  const validation = CreateUserSchema.safeParse(body);

  if (!validation.success) {
    return NextResponse.json(
      { success: false, errors: validation.error.flatten() },
      { status: 422 }
    );
  }

  // Safe to insert validation.data into database
  return NextResponse.json({ success: true, data: validation.data });
}

B. Global Middleware Guard

Instead of manually checking authentication tokens inside every single route file, use Next.js middleware.ts at the root of your project to guard API endpoints dynamically before requests reach your handler code.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const authToken = request.headers.get('authorization');

  // Protect all /api/protected/* routes
  if (request.nextUrl.pathname.startsWith('/api/protected')) {
    if (!authToken) {
      return NextResponse.json(
        { success: false, error: 'Unauthorized Access' },
        { status: 401 }
      );
    }
  }

  return NextResponse.next();
}

Architecture Comparison Table

Feature Pages Router (/pages/api) App Router (/app/api)
File Convention any-filename.ts route.ts
HTTP Handlers Single default export with switch Named exports (GET, POST, etc.)
Request/Response Objects Node.js req / res objects Standard Web API Request / Response
Runtime Options Node.js Serverless Runtime Node.js or Edge Runtime compatible

Conclusion

Next.js API Routes and Route Handlers turn your React application into a full-stack system without the operational complexity of managing external backend infrastructure. By migrating toward App Router Route Handlers, validating payloads with Zod, and securing endpoints with root middleware, you can build production-ready backend APIs directly inside Next.js.

Happy Coding! 🚀

Comments

Popular posts from this blog

React Performance Optimization: Profiling, Reconciliation, and Rendering Boundaries

Mastering React Icons: Installation, Customization, and Best Practices (2026 Guide)

How to Configure Webpack 5 with React from Scratch (2026 Guide)