Skip to main content
Back to Elite Events

Elite Events Documentation

Technical documentation, guides, and API references for the Elite Events platform.

Development Guides/API Route Checklist

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 withAuth or withAdmin
  • 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 select to limit fields
  • Avoid N+1 - Use include or DataLoader for relations
  • HTTP caching - Cacheable responses use cachedJsonResponse
  • Performance tracking - Use measureApiPerformance for 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 });
}
Documentation | Elite Events | Philip Rehberger