API Route Development Checklist
This checklist ensures consistent, secure, and well-documented API routes in Elite Events.
Quick Reference
// Minimal API route template
import { NextRequest, NextResponse } from 'next/server';
import { withErrorHandling, ApiError, successResponse } from '@/lib/api';
async function handleGet(request: NextRequest): Promise<NextResponse> {
// Implementation
return successResponse({ data: 'result' });
}
export const GET = withErrorHandling(handleGet);
Pre-Development Checklist
Before writing code, verify:
- Endpoint design - RESTful path matches resource (
/api/users,/api/products/[id]) - HTTP method - Appropriate method for action (GET=read, POST=create, PUT=update, DELETE=remove)
- Authentication needs - Public, authenticated, or admin-only?
- Rate limiting needs - What limits make sense for this endpoint?
- Caching strategy - Is this cacheable? What duration?
Implementation Checklist
1. File Setup
// src/app/api/my-resource/route.ts
// Force dynamic for authenticated/personalized routes
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import {
withErrorHandling,
withAuth,
withAdmin,
ApiError,
successResponse,
createdResponse,
noContentResponse,
type RouteContext,
} from '@/lib/api';
import { validateInput, mySchema } from '@/lib/validation-schemas';
import { checkRateLimit, rateLimitPresets, addSecurityHeaders } from '@/lib/security';
import { cachedJsonResponse, CACHE_PRESETS } from '@/lib/core/http-cache';
2. Handler Implementation
// GET - List/Read
async function handleGet(request: NextRequest): Promise<NextResponse> {
// 1. Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(`api-resource-${ip}`, rateLimitPresets.standard.limit, rateLimitPresets.standard.windowMs)) {
throw ApiError.rateLimited('Rate limit exceeded');
}
// 2. Parse query parameters
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1');
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
// 3. Database query
const [items, total] = await Promise.all([
prisma.resource.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.resource.count(),
]);
// 4. Return with security headers and caching
return addSecurityHeaders(
cachedJsonResponse(
request,
{
items,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
},
CACHE_PRESETS.productList
)
);
}
// POST - Create (with authentication)
async function handlePost(
request: NextRequest,
_context: RouteContext | undefined,
_session: unknown,
user: { id: string; role: string }
): Promise<NextResponse> {
// 1. Parse and validate body
const body = await request.json();
const validation = validateInput(createResourceSchema, body);
if (!validation.success) {
throw ApiError.validation('Validation failed', validation.errors);
}
// 2. Business logic / authorization
// ...
// 3. Database operation
const resource = await prisma.resource.create({
data: {
...validation.data,
userId: user.id,
},
});
// 4. Return created response
return createdResponse(resource);
}
// PUT - Update
async function handlePut(
request: NextRequest,
context: RouteContext | undefined
): Promise<NextResponse> {
const id = context?.params?.id;
if (!id) throw ApiError.notFound('Resource not found');
const body = await request.json();
const validation = validateInput(updateResourceSchema, body);
if (!validation.success) {
throw ApiError.validation('Validation failed', validation.errors);
}
const resource = await prisma.resource.update({
where: { id: parseInt(id) },
data: validation.data,
});
return successResponse(resource);
}
// DELETE
async function handleDelete(
request: NextRequest,
context: RouteContext | undefined
): Promise<NextResponse> {
const id = context?.params?.id;
if (!id) throw ApiError.notFound('Resource not found');
await prisma.resource.delete({
where: { id: parseInt(id) },
});
return noContentResponse();
}
3. Export with Middleware
// Public endpoint
export const GET = withErrorHandling(handleGet);
// Authenticated endpoint (requires login)
export const POST = withErrorHandling(withAuth(handlePost));
// Admin-only endpoint
export const PUT = withErrorHandling(withAdmin(handlePut));
export const DELETE = withErrorHandling(withAdmin(handleDelete));
4. Add to API Registry
// src/lib/api-docs/endpoints/my-resource.ts
import type { ApiEndpoint } from '@/types/api-docs';
export const myResourceEndpoints: ApiEndpoint[] = [
{
path: '/api/my-resource',
method: 'GET',
summary: 'List all resources',
description: 'Retrieves a paginated list of resources',
auth: false,
tags: ['Resources'],
parameters: [
{ name: 'page', type: 'number', required: false, description: 'Page number' },
{ name: 'limit', type: 'number', required: false, description: 'Items per page' },
],
responses: {
200: { description: 'List of resources' },
429: { description: 'Rate limit exceeded' },
},
},
{
path: '/api/my-resource',
method: 'POST',
summary: 'Create a resource',
description: 'Creates a new resource',
auth: true,
tags: ['Resources'],
body: {
name: { type: 'string', required: true },
description: { type: 'string', required: false },
},
responses: {
201: { description: 'Resource created' },
400: { description: 'Validation error' },
401: { description: 'Unauthorized' },
},
},
];
Then add to registry:
// src/lib/api-docs/endpoints/index.ts
export { myResourceEndpoints } from './my-resource';
// src/lib/api-docs/registry.ts
import { myResourceEndpoints } from './endpoints';
const categories: ApiCategory[] = [
// ... existing categories
{
id: 'my-resource',
name: 'My Resource',
description: 'Resource management endpoints',
icon: 'box',
endpoints: myResourceEndpoints,
},
];
Error Handling Patterns
Using ApiError
// Not found
throw ApiError.notFound('Product not found');
// Validation error
throw ApiError.validation('Invalid input', [
{ field: 'email', message: 'Invalid email format' },
]);
// Unauthorized
throw ApiError.unauthorized('Please log in');
// Forbidden
throw ApiError.forbidden('Admin access required');
// Rate limited
throw ApiError.rateLimited('Too many requests');
// Conflict
throw ApiError.conflict('Email already exists');
// Server error
throw ApiError.internal('Database connection failed');
Error Response Format
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
Response Helpers
import {
successResponse, // 200 OK
createdResponse, // 201 Created
noContentResponse, // 204 No Content
paginatedResponse, // 200 with pagination meta
} from '@/lib/api';
// Success with data
return successResponse({ user });
// Created with data
return createdResponse(newProduct);
// No content (for DELETE)
return noContentResponse();
// Paginated list
return paginatedResponse(items, {
page: 1,
limit: 20,
total: 100,
totalPages: 5,
});
Testing Checklist
Create tests in src/app/api/my-resource/__tests__/:
// route.test.ts
import { GET, POST } from '../route';
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
// Mock prisma
jest.mock('@/lib/prisma', () => ({
prisma: {
resource: {
findMany: jest.fn(),
count: jest.fn(),
create: jest.fn(),
},
},
}));
// Mock auth if needed
jest.mock('@/lib/auth', () => ({
getServerSession: jest.fn(),
}));
describe('GET /api/my-resource', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns paginated resources', async () => {
const mockResources = [{ id: 1, name: 'Test' }];
(prisma.resource.findMany as jest.Mock).mockResolvedValue(mockResources);
(prisma.resource.count as jest.Mock).mockResolvedValue(1);
const request = new NextRequest('http://localhost/api/my-resource');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.items).toEqual(mockResources);
expect(data.pagination).toBeDefined();
});
it('handles empty results', async () => {
(prisma.resource.findMany as jest.Mock).mockResolvedValue([]);
(prisma.resource.count as jest.Mock).mockResolvedValue(0);
const request = new NextRequest('http://localhost/api/my-resource');
const response = await GET(request);
const data = await response.json();
expect(data.items).toEqual([]);
expect(data.pagination.total).toBe(0);
});
it('respects pagination parameters', async () => {
(prisma.resource.findMany as jest.Mock).mockResolvedValue([]);
(prisma.resource.count as jest.Mock).mockResolvedValue(0);
const request = new NextRequest('http://localhost/api/my-resource?page=2&limit=10');
await GET(request);
expect(prisma.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 10,
take: 10,
})
);
});
});
describe('POST /api/my-resource', () => {
it('creates resource with valid data', async () => {
const mockResource = { id: 1, name: 'New Resource' };
(prisma.resource.create as jest.Mock).mockResolvedValue(mockResource);
const request = new NextRequest('http://localhost/api/my-resource', {
method: 'POST',
body: JSON.stringify({ name: 'New Resource' }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.id).toBe(1);
});
it('returns 400 for invalid data', async () => {
const request = new NextRequest('http://localhost/api/my-resource', {
method: 'POST',
body: JSON.stringify({}), // Missing required fields
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});
Security Checklist
- Rate limiting - Applied appropriate rate limits
- Input validation - All inputs validated with Zod schemas
- Authentication - Protected endpoints wrapped with
withAuthorwithAdmin - Authorization - User can only access their own resources
- Security headers - Response wrapped with
addSecurityHeaders - SQL injection - Using Prisma (parameterized queries)
- No sensitive data in logs - Passwords, tokens not logged
Performance Checklist
- Pagination - Large lists are paginated
- Select only needed fields - Use Prisma
selectto limit fields - Avoid N+1 - Use
includeor DataLoader for relations - HTTP caching - Cacheable responses use
cachedJsonResponse - Performance tracking - Use
measureApiPerformancefor monitoring
Final Checklist
Before merging:
- Route handler implemented with proper error handling
- Input validation with Zod schemas
- Tests written with good coverage
- Added to API registry (
src/lib/api-docs/) - Security measures in place
- Documentation updated if needed
- TypeScript types properly defined
- No console.log statements (use logger)
Common Patterns
Dynamic Route with ID
// src/app/api/products/[id]/route.ts
import { NextRequest } from 'next/server';
import { withErrorHandling, ApiError, successResponse, RouteContext } from '@/lib/api';
async function handleGet(
request: NextRequest,
context: RouteContext
): Promise<NextResponse> {
const id = context?.params?.id;
if (!id) throw ApiError.notFound('Product not found');
const product = await prisma.product.findUnique({
where: { id: parseInt(id) },
});
if (!product) throw ApiError.notFound('Product not found');
return successResponse(product);
}
export const GET = withErrorHandling(handleGet);
Nested Routes
// src/app/api/products/[id]/reviews/route.ts
async function handleGet(
request: NextRequest,
context: RouteContext
): Promise<NextResponse> {
const productId = context?.params?.id;
const reviews = await prisma.review.findMany({
where: { productId: parseInt(productId) },
});
return successResponse(reviews);
}
File Upload Endpoint
async function handlePost(request: NextRequest): Promise<NextResponse> {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
throw ApiError.validation('No file provided');
}
// Process file...
return createdResponse({ url: uploadedUrl });
}
Related Documentation
- Validation Guide
- Testing Guide
- Security Best Practices