MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js API Routes: Building Backend in Next.js

Build API routes: middleware, error handling, database integration, and deployment.

Next.jsAPI RoutesBackendFull-Stack

By MinhVo

Introduction

Next.js has revolutionized full-stack React development by unifying frontend and backend code within a single framework. API Routes—now evolved into Route Handlers in the App Router—allow developers to build robust backend endpoints without maintaining a separate server, eliminating the complexity of coordinating deployments, managing CORS, and synchronizing types across two codebases.

Next.js full-stack architecture

This unification is more than convenience—it fundamentally changes how teams build web applications. Shared TypeScript types ensure frontend and backend stay synchronized. Server-side rendering can call internal APIs without network overhead. Authentication state flows seamlessly between pages and endpoints. The result is faster development cycles, fewer integration bugs, and simpler deployment pipelines.

This comprehensive guide covers everything you need to build production-grade APIs with Next.js. From basic route creation to advanced middleware patterns, database integration, authentication, error handling, and deployment optimization, you'll learn how to leverage Next.js API Routes for real-world applications that handle thousands of concurrent users.

Understanding API Routes: Core Concepts

How API Routes Work

API Routes in Next.js are files that export functions handling HTTP requests. In the Pages Router, they live in the pages/api/ directory and export a default handler function. In the App Router, they use route.ts files with exported named functions matching HTTP methods (GET, POST, PUT, DELETE).

The key insight is that API Routes run on the server—in Node.js, not in the browser. This means you can safely access databases, read environment variables, use filesystem operations, and call third-party APIs without exposing secrets to the client.

Pages Router vs App Router

Pages Router (pages/api/) uses a single handler function that receives NextApiRequest and NextApiResponse:

// 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 getUserById(id as string);
      return res.status(200).json(user);
    case 'PUT':
      const updated = await updateUser(id as string, req.body);
      return res.status(200).json(updated);
    case 'DELETE':
      await deleteUser(id as string);
      return res.status(204).end();
    default:
      res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
      return res.status(405).json({ error: `Method ${req.method} not allowed` });
  }
}

App Router (app/api/) uses the Web API standard with Request and Response objects:

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
 
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const user = await getUserById(params.id);
  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }
  return NextResponse.json(user);
}
 
export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await request.json();
  const updated = await updateUser(params.id, body);
  return NextResponse.json(updated);
}
 
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  await deleteUser(params.id);
  return new NextResponse(null, { status: 204 });
}

Request and Response Lifecycle

Every API request goes through a predictable lifecycle: the request arrives at the Next.js server, middleware intercepts it (if configured), the route handler processes it, and a response is sent back. Understanding this lifecycle is crucial for implementing authentication, logging, and error handling correctly.

The App Router's adoption of Web API standards means your route handlers are compatible with the broader JavaScript ecosystem—libraries that work with Request and Response objects integrate seamlessly.

Full-stack development workflow

Architecture and Design Patterns

Middleware Pattern

Next.js Middleware runs before requests reach your route handlers, enabling authentication, redirects, and request modification at the edge:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from './lib/auth';
 
const publicPaths = ['/api/auth/login', '/api/auth/register', '/api/health'];
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Allow public paths
  if (publicPaths.some(path => pathname.startsWith(path))) {
    return NextResponse.next();
  }
 
  // Check authentication for API routes
  if (pathname.startsWith('/api/')) {
    const token = request.headers.get('authorization')?.replace('Bearer ', '');
 
    if (!token) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      );
    }
 
    try {
      const payload = await verifyToken(token);
      const requestHeaders = new Headers(request.headers);
      requestHeaders.set('x-user-id', payload.userId);
      requestHeaders.set('x-user-role', payload.role);
 
      return NextResponse.next({
        request: { headers: requestHeaders },
      });
    } catch {
      return NextResponse.json(
        { error: 'Invalid or expired token' },
        { status: 401 }
      );
    }
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ['/api/:path*'],
};

Route Handler Composition

Create reusable wrappers for common patterns like authentication checks, validation, and error handling:

// lib/api-handler.ts
import { NextRequest, NextResponse } from 'next/server';
import { z, ZodSchema } from 'zod';
 
type RouteHandler = (
  request: NextRequest,
  context: { params: Record<string, string> }
) => Promise<NextResponse>;
 
interface HandlerOptions {
  schema?: ZodSchema;
  requireAuth?: boolean;
  requiredRole?: string;
}
 
export function createHandler(
  handler: RouteHandler,
  options: HandlerOptions = {}
): RouteHandler {
  return async (request, context) => {
    try {
      // Authentication check
      if (options.requireAuth) {
        const userId = request.headers.get('x-user-id');
        if (!userId) {
          return NextResponse.json(
            { error: 'Authentication required' },
            { status: 401 }
          );
        }
 
        const userRole = request.headers.get('x-user-role');
        if (options.requiredRole && userRole !== options.requiredRole) {
          return NextResponse.json(
            { error: 'Insufficient permissions' },
            { status: 403 }
          );
        }
      }
 
      // Request validation
      if (options.schema && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
        const body = await request.json();
        const result = options.schema.safeParse(body);
        if (!result.success) {
          return NextResponse.json(
            { error: 'Validation failed', details: result.error.flatten() },
            { status: 400 }
          );
        }
        // Attach validated body to request
        (request as any).validatedBody = result.data;
      }
 
      return await handler(request, context);
    } catch (error) {
      return handleApiError(error);
    }
  };
}
 
function handleApiError(error: unknown): NextResponse {
  console.error('API Error:', error);
 
  if (error instanceof AppError) {
    return NextResponse.json(
      { error: error.message, code: error.code },
      { status: error.statusCode }
    );
  }
 
  if (error instanceof z.ZodError) {
    return NextResponse.json(
      { error: 'Validation error', details: error.flatten() },
      { status: 400 }
    );
  }
 
  return NextResponse.json(
    { error: 'Internal server error' },
    { status: 500 }
  );
}
 
class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number = 500,
    public code?: string
  ) {
    super(message);
    this.name = 'AppError';
  }
}

Service Layer Pattern

Separate business logic from route handlers using a service layer:

// services/user-service.ts
export class UserService {
  constructor(private db: DatabaseClient) {}
 
  async findAll(options: QueryOptions): Promise<PaginatedResult<User>> {
    const { page = 1, limit = 20, search, sortBy = 'createdAt', order = 'desc' } = options;
 
    const query = this.db.user.findMany({
      where: search ? {
        OR: [
          { name: { contains: search, mode: 'insensitive' } },
          { email: { contains: search, mode: 'insensitive' } },
        ],
      } : undefined,
      orderBy: { [sortBy]: order },
      skip: (page - 1) * limit,
      take: limit,
      select: {
        id: true, email: true, name: true, createdAt: true,
      },
    });
 
    const [data, total] = await Promise.all([
      query,
      this.db.user.count({ where: query.where }),
    ]);
 
    return { data, total, page, limit, totalPages: Math.ceil(total / limit) };
  }
 
  async create(data: CreateUserInput): Promise<User> {
    const existing = await this.db.user.findUnique({ where: { email: data.email } });
    if (existing) throw new AppError('Email already exists', 409, 'EMAIL_CONFLICT');
 
    const hashedPassword = await bcrypt.hash(data.password, 12);
    return this.db.user.create({
      data: { ...data, password: hashedPassword },
    });
  }
 
  async update(id: string, data: UpdateUserInput, currentUserId: string): Promise<User> {
    if (id !== currentUserId) {
      const currentUser = await this.db.user.findUnique({ where: { id: currentUserId } });
      if (currentUser?.role !== 'admin') {
        throw new AppError('Cannot update other users', 403, 'FORBIDDEN');
      }
    }
 
    return this.db.user.update({ where: { id }, data });
  }
}
 
// app/api/users/route.ts
import { createHandler } from '@/lib/api-handler';
import { UserService } from '@/services/user-service';
 
const userService = new UserService(getDatabaseClient());
 
export const GET = createHandler(
  async (request) => {
    const searchParams = request.nextUrl.searchParams;
    const options = {
      page: parseInt(searchParams.get('page') || '1'),
      limit: parseInt(searchParams.get('limit') || '20'),
      search: searchParams.get('search') || undefined,
      sortBy: searchParams.get('sortBy') || 'createdAt',
      order: (searchParams.get('order') || 'desc') as 'asc' | 'desc',
    };
 
    const result = await userService.findAll(options);
    return NextResponse.json(result);
  },
  { requireAuth: true }
);
 
export const POST = createHandler(
  async (request) => {
    const body = (request as any).validatedBody;
    const user = await userService.create(body);
    return NextResponse.json(user, { status: 201 });
  },
  {
    requireAuth: true,
    requiredRole: 'admin',
    schema: createUserSchema,
  }
);

Step-by-Step Implementation

Database Integration with Prisma

// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
 
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
 
export const prisma = globalForPrisma.prisma || new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
 
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
 
// app/api/posts/route.ts
import { prisma } from '@/lib/prisma';
import { createHandler } from '@/lib/api-handler';
import { z } from 'zod';
 
const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  category: z.enum(['tech', 'lifestyle', 'business']),
  tags: z.array(z.string()).max(10).optional(),
  published: z.boolean().default(false),
});
 
export const GET = createHandler(async (request) => {
  const { searchParams } = request.nextUrl;
  const page = parseInt(searchParams.get('page') || '1');
  const limit = parseInt(searchParams.get('limit') || '10');
  const category = searchParams.get('category');
 
  const where = { published: true, ...(category && { category }) };
 
  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      where,
      include: {
        author: { select: { id: true, name: true, avatar: true } },
        tags: true,
        _count: { select: { comments: true, likes: true } },
      },
      orderBy: { createdAt: 'desc' },
      skip: (page - 1) * limit,
      take: limit,
    }),
    prisma.post.count({ where }),
  ]);
 
  return NextResponse.json({
    data: posts,
    pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
  });
});
 
export const POST = createHandler(async (request) => {
  const body = (request as any).validatedBody;
  const userId = request.headers.get('x-user-id')!;
 
  const post = await prisma.post.create({
    data: {
      ...body,
      authorId: userId,
      tags: { connectOrCreate: body.tags?.map(tag => ({
        where: { name: tag },
        create: { name: tag },
      })) },
    },
    include: { author: true, tags: true },
  });
 
  return NextResponse.json(post, { status: 201 });
}, { requireAuth: true, schema: createPostSchema });

Authentication with NextAuth.js

// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider from 'next-auth/providers/google';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
 
const handler = 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) {
        if (!credentials?.email || !credentials?.password) {
          throw new Error('Missing credentials');
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email },
        });
 
        if (!user || !user.password) {
          throw new Error('Invalid credentials');
        }
 
        const isValid = await bcrypt.compare(credentials.password, user.password);
        if (!isValid) {
          throw new Error('Invalid credentials');
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        };
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.role = token.role as string;
        session.user.id = token.id as string;
      }
      return session;
    },
  },
  session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60 },
  pages: { signIn: '/auth/login', error: '/auth/error' },
});
 
export { handler as GET, handler as POST };

File Upload Handling

// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { v4 as uuid } from 'uuid';
 
export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File;
 
    if (!file) {
      return NextResponse.json({ error: 'No file provided' }, { status: 400 });
    }
 
    const maxSize = 10 * 1024 * 1024; // 10MB
    if (file.size > maxSize) {
      return NextResponse.json({ error: 'File too large' }, { status: 400 });
    }
 
    const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
    if (!allowedTypes.includes(file.type)) {
      return NextResponse.json({ error: 'Invalid file type' }, { status: 400 });
    }
 
    const bytes = await file.arrayBuffer();
    const buffer = Buffer.from(bytes);
 
    const ext = file.name.split('.').pop();
    const filename = `${uuid()}.${ext}`;
    const uploadDir = join(process.cwd(), 'public', 'uploads');
 
    await mkdir(uploadDir, { recursive: true });
    await writeFile(join(uploadDir, filename), buffer);
 
    return NextResponse.json({
      url: `/uploads/${filename}`,
      filename,
      size: file.size,
      type: file.type,
    });
  } catch (error) {
    console.error('Upload error:', error);
    return NextResponse.json(
      { error: 'Upload failed' },
      { status: 500 }
    );
  }
}

Streaming Responses

// app/api/chat/route.ts
import { NextRequest } from 'next/server';
import OpenAI from 'openai';
 
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
 
export async function POST(request: NextRequest) {
  const { messages } = await request.json();
 
  const stream = await openai.chat.completions.create({
    model: 'gpt-4',
    messages,
    stream: true,
    max_tokens: 2000,
  });
 
  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      for await (const chunk of stream) {
        const content = chunk.choices[0]?.delta?.content;
        if (content) {
          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content })}\n\n`));
        }
      }
      controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      controller.close();
    },
  });
 
  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

Backend implementation patterns

Real-World Use Cases

Use Case 1: E-Commerce API

An e-commerce platform built with Next.js API Routes handles product listings, cart management, order processing, and payment integration. Server components fetch product data at build time for SEO, while API Routes handle dynamic operations like cart updates and checkout. Webhooks from Stripe update order status in real-time through dedicated API Route endpoints.

Use Case 2: Content Management System

A headless CMS uses API Routes to provide a RESTful API for content CRUD operations. Route handlers manage authentication, validation, and database operations while middleware handles rate limiting and request logging. The same Next.js application serves the admin dashboard and the public-facing content API.

Use Case 3: Real-Time Dashboard Backend

API Routes power a real-time analytics dashboard by providing endpoints for data aggregation, time-series queries, and export functionality. Server-Sent Events (SSE) stream live updates to connected clients, while background jobs process incoming events and update aggregated metrics.

Use Case 4: Multi-Tenant SaaS Platform

A SaaS application uses API Routes with middleware to extract tenant information from subdomains or headers, ensuring data isolation between tenants. The service layer enforces tenant-specific business rules, while Prisma's multi-tenant schema keeps database queries scoped correctly.

Best Practices for Production

  1. Validate All Inputs with Zod: Define schemas for every request body, query parameter, and path parameter. Use Zod's safeParse for non-throwing validation and return structured error responses that help frontend developers fix issues quickly.

  2. Implement Proper Error Handling: Create a centralized error handler that catches different error types (validation, authentication, database, external API) and returns consistent error responses with appropriate HTTP status codes, error codes, and human-readable messages.

  3. Use Response Helpers Consistently: Create helper functions for common response patterns—paginated responses, success messages, error responses. This ensures consistent API contract across all endpoints and reduces boilerplate.

  4. Implement Rate Limiting: Protect API endpoints from abuse using rate limiting. Use Redis-backed rate limiters for distributed deployments, and configure different limits for authenticated versus anonymous users.

  5. Cache Database Queries: Implement caching at the service layer using Redis or Next.js's built-in cache. Cache frequently accessed data with appropriate TTLs and implement cache invalidation strategies for write operations.

  6. Use Database Transactions: When operations involve multiple database writes, use transactions to ensure atomicity. Prisma's interactive transactions are perfect for order processing, payment handling, and other multi-step operations.

  7. Implement Proper Logging: Log all API requests with correlation IDs, timing information, and user context. Use structured logging (JSON format) for production environments to enable powerful querying with tools like Datadog or ELK stack.

  8. Secure API Endpoints: Implement CORS policies, sanitize inputs to prevent SQL injection, validate file uploads, rate limit endpoints, and use HTTP-only cookies for authentication tokens. Never trust client-side data.

Common Pitfalls and Solutions

PitfallImpactSolution
No input validationSecurity vulnerabilities, data corruptionUse Zod schemas with global validation middleware
Exposing internal errorsInformation leakageCatch errors and return generic messages for 500s
Missing CORS headersFrontend can't call APIConfigure CORS in middleware or Next.js config
Database connection leaksConnection pool exhaustionUse singleton Prisma client pattern
No paginationMemory issues with large datasetsAlways implement cursor or offset pagination
Synchronous file operationsBlocks event loop, poor concurrencyUse async fs operations and streaming

Performance Optimization

// lib/cache.ts
import { Redis } from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL!);
 
export async function withCache<T>(
  key: string,
  ttl: number,
  fetcher: () => Promise<T>
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
 
  const data = await fetcher();
  await redis.setex(key, ttl, JSON.stringify(data));
  return data;
}
 
// Usage in route handler
export const GET = createHandler(async (request) => {
  const { id } = request.params;
 
  const product = await withCache(`product:${id}`, 300, () =>
    prisma.product.findUnique({
      where: { id },
      include: { category: true, reviews: { take: 5, orderBy: { createdAt: 'desc' } } },
    })
  );
 
  if (!product) {
    return NextResponse.json({ error: 'Product not found' }, { status: 404 });
  }
 
  return NextResponse.json(product);
});

Key optimizations include connection pooling with Prisma for efficient database connections, implementing cursor-based pagination for consistent performance regardless of page number, using edge runtime for lightweight endpoints that don't need Node.js APIs, and compressing responses with gzip/brotli to reduce bandwidth.

Comparison with Alternatives

FeatureNext.js API RoutesExpress.jsServerless FunctionstRPC
Setup ComplexityMinimalModerateLowModerate
Type SafetyManualManualManualAutomatic
Cold StartFastN/A (always on)VariableFast
DeploymentUnified with frontendSeparateSeparateUnified
MiddlewareBuilt-inExtensiveProvider-specificLimited
StreamingSupportedSupportedVariesSupported
Best ForFull-stack appsDedicated APIsEvent-drivenType-safe apps

Advanced Patterns

Custom Error Classes

// lib/errors.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string,
    public details?: Record<string, any>
  ) {
    super(message);
    this.name = 'ApiError';
  }
 
  static badRequest(message: string, details?: Record<string, any>) {
    return new ApiError(400, message, 'BAD_REQUEST', details);
  }
 
  static unauthorized(message = 'Unauthorized') {
    return new ApiError(401, message, 'UNAUTHORIZED');
  }
 
  static forbidden(message = 'Forbidden') {
    return new ApiError(403, message, 'FORBIDDEN');
  }
 
  static notFound(resource = 'Resource') {
    return new ApiError(404, `${resource} not found`, 'NOT_FOUND');
  }
 
  static conflict(message: string) {
    return new ApiError(409, message, 'CONFLICT');
  }
 
  static internal(message = 'Internal server error') {
    return new ApiError(500, message, 'INTERNAL_ERROR');
  }
}

API Versioning

// middleware.ts - API versioning
export function middleware(request: NextRequest) {
  const url = request.nextUrl.clone();
  const pathname = url.pathname;
 
  // Route /api/v1/* to /api/*
  if (pathname.startsWith('/api/v1/')) {
    url.pathname = pathname.replace('/api/v1/', '/api/');
    return NextResponse.rewrite(url);
  }
 
  // Default to latest version
  if (pathname.startsWith('/api/') && !pathname.startsWith('/api/v')) {
    const acceptHeader = request.headers.get('accept-version');
    if (acceptHeader === '2') {
      url.pathname = pathname.replace('/api/', '/api/v2/');
      return NextResponse.rewrite(url);
    }
  }
 
  return NextResponse.next();
}

Testing Strategies

// __tests__/api/users.test.ts
import { createMocks } from 'node-mocks-http';
import { GET, POST } from '@/app/api/users/route';
import { prisma } from '@/lib/prisma';
 
describe('/api/users', () => {
  beforeEach(async () => {
    await prisma.user.deleteMany();
  });
 
  describe('GET', () => {
    it('returns paginated users', async () => {
      await prisma.user.createMany({
        data: [
          { email: 'user1@test.com', name: 'User 1', password: 'hash' },
          { email: 'user2@test.com', name: 'User 2', password: 'hash' },
        ],
      });
 
      const request = new Request('http://localhost/api/users?page=1&limit=10');
      const response = await GET(request, { params: {} });
      const data = await response.json();
 
      expect(response.status).toBe(200);
      expect(data.data).toHaveLength(2);
      expect(data.pagination.total).toBe(2);
    });
 
    it('filters users by search term', async () => {
      await prisma.user.createMany({
        data: [
          { email: 'john@test.com', name: 'John Doe', password: 'hash' },
          { email: 'jane@test.com', name: 'Jane Smith', password: 'hash' },
        ],
      });
 
      const request = new Request('http://localhost/api/users?search=john');
      const response = await GET(request, { params: {} });
      const data = await response.json();
 
      expect(data.data).toHaveLength(1);
      expect(data.data[0].name).toBe('John Doe');
    });
  });
 
  describe('POST', () => {
    it('creates a new user', async () => {
      const request = new Request('http://localhost/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-user-id': 'admin-id',
          'x-user-role': 'admin',
        },
        body: JSON.stringify({
          email: 'new@test.com',
          name: 'New User',
          password: 'StrongP@ss1',
        }),
      });
 
      const response = await POST(request, { params: {} });
      const data = await response.json();
 
      expect(response.status).toBe(201);
      expect(data.email).toBe('new@test.com');
      expect(data).not.toHaveProperty('password');
    });
  });
});

Future Outlook

Next.js API Routes continue to evolve with the framework. The App Router brings significant improvements including streaming support, improved caching with revalidateTag, and tighter integration with React Server Components. Edge Runtime support enables deploying API endpoints to edge locations worldwide for ultra-low latency responses.

The convergence of server components and API routes is creating new patterns where data fetching moves to the server component layer, leaving API Routes for mutations and external integrations. This shift simplifies frontend code while maintaining the flexibility of a full API layer.

Conclusion

Next.js API Routes provide a powerful, flexible foundation for building backend services within your Next.js application. By following the patterns and practices outlined in this guide, you can build APIs that are secure, performant, and maintainable.

Key takeaways for building production-grade APIs with Next.js:

  1. Use the App Router's Route Handlers: They follow Web API standards and offer better TypeScript support than the Pages Router
  2. Implement middleware for cross-cutting concerns: Authentication, logging, and rate limiting belong in middleware, not route handlers
  3. Create a service layer: Separate business logic from route handlers for testability and reusability
  4. Validate everything with Zod: Define schemas for all inputs and return structured validation errors
  5. Handle errors consistently: Create centralized error handling that returns consistent response formats
  6. Cache aggressively: Implement caching at the service layer to reduce database load
  7. Test at every level: Write unit tests for services, integration tests for route handlers, and E2E tests for critical flows

Next.js has evolved from a React framework into a full-stack application platform. By mastering API Routes, you unlock the ability to build complete applications—from database to UI—within a single, cohesive codebase. This simplifies development, reduces deployment complexity, and enables patterns like server-side rendering and streaming that enhance the user experience.

For deeper exploration, study the official Next.js documentation on Route Handlers, experiment with different database integrations, and examine open-source Next.js applications to see these patterns applied in real-world production contexts.