Back to blog

Building Scalable APIs with Next.js Route Handlers

June 16, 2024 (1y ago)

Next.js App Router introduced route handlers that make building APIs simpler and more powerful than ever. Let's explore how to build scalable APIs with this new paradigm.

Route Handlers Basics

Route handlers replace the old pages/api directory structure:

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

export async function GET(request: NextRequest) {
  const users = await getUsers();
  return NextResponse.json(users);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const user = await createUser(body);
  return NextResponse.json(user, { status: 201 });
}

Type Safety with Zod

Always validate your input data:

import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  role: z.enum(['user', 'admin']),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const validatedBody = CreateUserSchema.parse(body);
    
    const user = await createUser(validatedBody);
    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: error.errors },
        { status: 400 }
      );
    }
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Dynamic Routes

Dynamic route handlers work similarly to pages:

// app/api/users/[id]/route.ts
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 user = await updateUser(params.id, body);
  return NextResponse.json(user);
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  await deleteUser(params.id);
  return NextResponse.json({ success: true });
}

Middleware and Authentication

Implement authentication at the route level:

// lib/auth.ts
export async function requireAuth(request: NextRequest) {
  const token = request.headers.get('Authorization');
  
  if (!token) {
    throw new Error('No token provided');
  }
  
  const user = await verifyToken(token);
  return user;
}

// app/api/protected/route.ts
export async function GET(request: NextRequest) {
  try {
    const user = await requireAuth(request);
    return NextResponse.json({ user });
  } catch (error) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }
}

Error Handling

Create consistent error responses:

// lib/errors.ts
export class ApiError extends Error {
  constructor(
    public message: string,
    public status: number = 500
  ) {
    super(message);
  }
}

export function handleApiError(error: unknown) {
  if (error instanceof ApiError) {
    return NextResponse.json(
      { error: error.message },
      { status: error.status }
    );
  }
  
  if (error instanceof z.ZodError) {
    return NextResponse.json(
      { error: error.errors },
      { status: 400 }
    );
  }
  
  console.error('Unexpected error:', error);
  return NextResponse.json(
    { error: 'Internal server error' },
    { status: 500 }
  );
}

// Usage in routes
export async function POST(request: NextRequest) {
  try {
    // Your logic here
    return NextResponse.json({ success: true });
  } catch (error) {
    return handleApiError(error);
  }
}

Database Integration

Use route handlers with your database:

// lib/db.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;

// app/api/posts/route.ts
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get('page') || '1');
  const limit = parseInt(searchParams.get('limit') || '10');
  
  const posts = await prisma.post.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' },
    include: { author: true },
  });
  
  return NextResponse.json(posts);
}

Caching Strategies

Implement intelligent caching:

// app/api/posts/[slug]/route.ts
import { unstable_cache } from 'next/cache';

const getPostBySlug = unstable_cache(
  async (slug: string) => {
    return prisma.post.findUnique({
      where: { slug },
      include: { author: true, tags: true },
    });
  },
  ['post'],
  { revalidate: 3600, tags: ['posts'] }
);

export async function GET(
  request: NextRequest,
  { params }: { params: { slug: string } }
) {
  const post = await getPostBySlug(params.slug);
  
  if (!post) {
    return NextResponse.json(
      { error: 'Post not found' },
      { status: 404 }
    );
  }
  
  return NextResponse.json(post);
}

Rate Limiting

Protect your endpoints:

// lib/rate-limit.ts
const rateLimit = new Map<string, { count: number; resetTime: number }>();

export function rateLimitMiddleware(
  request: NextRequest,
  limit: number = 100,
  windowMs: number = 60000
) {
  const ip = request.ip || 'unknown';
  const now = Date.now();
  const record = rateLimit.get(ip);
  
  if (!record || now > record.resetTime) {
    rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
    return true;
  }
  
  if (record.count >= limit) {
    return false;
  }
  
  record.count++;
  return true;
}

// Usage in routes
export async function POST(request: NextRequest) {
  if (!rateLimitMiddleware(request, 10, 60000)) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }
  
  // Your logic here
}

File Uploads

Handle file uploads with route handlers:

// app/api/upload/route.ts
export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get('file') as File;
  
  if (!file) {
    return NextResponse.json(
      { error: 'No file provided' },
      { status: 400 }
    );
  }
  
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);
  
  // Save to storage (S3, local, etc.)
  const url = await saveFile(buffer, file.name);
  
  return NextResponse.json({ url });
}

Testing Route Handlers

Test your APIs effectively:

// __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', async () => {
    const request = new NextRequest('http://localhost/api/users');
    const response = await GET(request);
    
    expect(response.status).toBe(200);
    const data = await response.json();
    expect(Array.isArray(data)).toBe(true);
  });
  
  it('should create user', async () => {
    const userData = {
      name: 'John Doe',
      email: '[email protected]',
      role: 'user',
    };
    
    const request = new NextRequest('http://localhost/api/users', {
      method: 'POST',
      body: JSON.stringify(userData),
    });
    
    const response = await POST(request);
    expect(response.status).toBe(201);
  });
});

Best Practices

  1. Always validate input with Zod or similar
  2. Handle errors consistently across all endpoints
  3. Implement rate limiting for public APIs
  4. Use caching for expensive operations
  5. Write tests for all endpoints
  6. Document your API with OpenAPI/Swagger
  7. Monitor performance and errors
  8. Use environment variables for configuration

Conclusion

Next.js route handlers provide a powerful, type-safe way to build APIs. By following these patterns, you can create scalable, maintainable APIs that integrate seamlessly with your Next.js applications.

The key is to focus on type safety, error handling, and performance from the start. Your future self will thank you.