Response Helpers Migration Example
This document shows a complete before/after example of migrating an API route to use the standardized response helpers.
Before: Original Route (Inconsistent Responses)
// app/api/example/route.ts (BEFORE)
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/auth/route-auth';
import { logger } from '@/lib/utils/logging';
import { createClient } from '@/lib/utils/supabase/server';
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
search: z.string().optional(),
});
const createSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
// GET handler - inconsistent response format
export const GET = withAuth(async (request: NextRequest, session) => {
const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const startTime = Date.now();
try {
const searchParams = request.nextUrl.searchParams;
const queryResult = querySchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
search: searchParams.get('search'),
});
if (!queryResult.success) {
// Non-standard error format
return NextResponse.json(
{
error: 'Invalid query parameters',
details: queryResult.error.errors,
requestId,
},
{
status: 400,
headers: {
'X-Request-ID': requestId,
},
}
);
}
const { page, limit, search } = queryResult.data;
const skip = (page - 1) * limit;
const supabase = await createClient();
const where: any = {};
if (search) {
where['name'] = { contains: search, mode: 'insensitive' };
}
const [items, total] = await Promise.all([
supabase.from('items').select('*').range(skip, skip + limit - 1),
supabase.from('items').select('count')
]);
const pages = Math.ceil(total / limit);
// Inconsistent success format
return NextResponse.json(
{
data: {
items,
pagination: {
total,
page,
limit,
pages,
hasNext: page < pages,
hasPrevious: page > 1,
},
},
meta: {
requestId,
processingTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
},
},
{
status: 200,
headers: {
'X-Processing-Time': (Date.now() - startTime).toString(),
'X-Request-ID': requestId,
},
}
);
} catch (error) {
logger.error('Error fetching items', {
requestId,
error: error instanceof Error ? error.message : String(error),
});
// Different error format than validation errors
return NextResponse.json(
{
error: 'Failed to fetch items',
message: error instanceof Error ? error.message : 'Unknown error occurred',
requestId,
},
{
status: 500,
headers: {
'X-Request-ID': requestId,
},
}
);
}
});
// POST handler - different response format
export const POST = withAuth(async (request: NextRequest, session) => {
const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
try {
const body = await request.json();
const validationResult = createSchema.safeParse(body);
if (!validationResult.success) {
// Yet another error format
return NextResponse.json(
{
error: 'Invalid data',
details: validationResult.error.errors,
requestId,
},
{
status: 400,
headers: {
'X-Request-ID': requestId,
},
}
);
}
const { name, email } = validationResult.data;
const supabase = await createClient();
const { data: item, error } = await supabase
.from('items')
.insert({ name, email })
.select()
.single();
if (error) throw error;
// Yet another success format
return NextResponse.json(
{
data: { item },
meta: {
requestId,
timestamp: new Date().toISOString(),
},
},
{
status: 201,
headers: {
Location: `/api/items/${item.id}`,
'X-Request-ID': requestId,
},
}
);
} catch (error) {
logger.error('Error creating item', {
requestId,
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'Failed to create item',
message: error instanceof Error ? error.message : 'Unknown error occurred',
requestId,
},
{
status: 500,
headers: {
'X-Request-ID': requestId,
},
}
);
}
});
After: Standardized Route (Using Response Helpers)
// app/api/example/route.ts (AFTER)
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/auth/route-auth';
import { apiSuccess, apiCreated, ApiErrors, apiPaginated } from '@/lib/api';
import { logger } from '@/lib/utils/logging';
import { createClient } from '@/lib/utils/supabase/server';
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
search: z.string().optional(),
});
const createSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
// Helper to format Zod errors for validation responses
function formatZodErrors(error: z.ZodError): Record<string, string[]> {
const formatted: Record<string, string[]> = {};
error.errors.forEach((err) => {
const path = err.path.join('.');
if (!formatted[path]) formatted[path] = [];
formatted[path].push(err.message);
});
return formatted;
}
// GET handler - clean and standardized
export const GET = withAuth(async (request: NextRequest, session) => {
const requestId = crypto.randomUUID(); // Use crypto.randomUUID() for better IDs
try {
const searchParams = request.nextUrl.searchParams;
const queryResult = querySchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
search: searchParams.get('search'),
});
if (!queryResult.success) {
// Standardized validation error
const errors = formatZodErrors(queryResult.error);
return ApiErrors.validationError(errors, requestId);
}
const { page, limit, search } = queryResult.data;
const skip = (page - 1) * limit;
const supabase = await createClient();
const where: any = {};
if (search) {
where['name'] = { contains: search, mode: 'insensitive' };
}
const [items, total] = await Promise.all([
supabase.from('items').select('*').range(skip, skip + limit - 1),
supabase.from('items').select('count')
]);
// Standardized paginated response - much simpler!
return apiPaginated(items, { page, pageSize: limit, total }, { requestId });
} catch (error) {
logger.error('Error fetching items', {
requestId,
error: error instanceof Error ? error.message : String(error),
});
// Standardized server error
return ApiErrors.serverError('Failed to fetch items', requestId);
}
});
// POST handler - clean and standardized
export const POST = withAuth(async (request: NextRequest, session) => {
const requestId = crypto.randomUUID();
try {
const body = await request.json();
const validationResult = createSchema.safeParse(body);
if (!validationResult.success) {
// Standardized validation error
const errors = formatZodErrors(validationResult.error);
return ApiErrors.validationError(errors, requestId);
}
const { name, email } = validationResult.data;
const supabase = await createClient();
const { data: item, error } = await supabase
.from('items')
.insert({ name, email })
.select()
.single();
if (error) throw error;
// Standardized creation response - includes Location header automatically!
return apiCreated(item, `/api/items/${item.id}`, { requestId });
} catch (error) {
logger.error('Error creating item', {
requestId,
error: error instanceof Error ? error.message : String(error),
});
// Standardized server error
return ApiErrors.serverError('Failed to create item', requestId);
}
});
Key Improvements
1. Consistent Response Format
Before: Every response had a different structure
// Validation error
{ error: '...', details: [...], requestId: '...' }
// Success with pagination
{ data: { items, pagination }, meta: { requestId, processingTime, timestamp } }
// Success with single item
{ data: { item }, meta: { requestId, timestamp } }
After: All responses follow the same structure
{
success: boolean,
data: T | null,
error: string | null,
requestId: string,
timestamp: number
}
2. Simpler Code
Before: 15+ lines for a single error response
return NextResponse.json(
{
error: 'Invalid query parameters',
details: queryResult.error.errors,
requestId,
},
{
status: 400,
headers: {
'X-Request-ID': requestId,
},
}
);
After: 1-2 lines for any error
return ApiErrors.validationError(errors, requestId);
3. Better Type Safety
Before: Manual type definitions, easy to make mistakes
const response: { data?: any; error?: string; meta?: any } = {
data: { items },
meta: { requestId }
};
After: Automatic type inference
const response = apiSuccess(items); // TypeScript knows the exact type
4. Automatic Headers
Before: Manually set headers for each response
headers: {
'X-Request-ID': requestId,
'X-Processing-Time': processingTime.toString(),
}
After: Headers set automatically
// X-Request-ID, X-Response-Time, Content-Type, Cache-Control all automatic
return apiSuccess(data, { requestId });
5. Pagination Made Easy
Before: 20+ lines of pagination logic
const pages = Math.ceil(total / limit);
return NextResponse.json({
data: {
items,
pagination: {
total,
page,
limit,
pages,
hasNext: page < pages,
hasPrevious: page > 1,
},
},
// ... more boilerplate
});
After: 1 line with automatic calculation
return apiPaginated(items, { page, pageSize: limit, total }, { requestId });
6. Consistent Error Handling
Before: Different error formats everywhere
// Validation errors
{ error: 'Invalid data', details: [...] }
// Server errors
{ error: 'Failed', message: '...' }
// Not found errors
{ error: 'Not found' }
After: All errors follow the same standard
ApiErrors.validationError(errors, requestId);
ApiErrors.serverError('Failed', requestId);
ApiErrors.notFound('Item', requestId);
Code Metrics Comparison
Lines of Code
- Before: ~150 lines for GET + POST
- After: ~80 lines for GET + POST
- Reduction: ~47% fewer lines
Response Variants
- Before: 4 different response formats
- After: 1 standard response format
- Improvement: 75% reduction in complexity
Header Management
- Before: Manual header setting in 8 places
- After: Automatic headers everywhere
- Improvement: 100% reduction in manual header code
Error Handling
- Before: 3 different error response formats
- After: 1 standard error format with type helpers
- Improvement: Consistent error handling across all routes
Testing Comparison
Before
test('GET returns items', async () => {
const response = await GET(request, session);
const json = await response.json();
// What shape is the response? Let me check the implementation...
expect(json.data).toBeDefined();
expect(json.data.items).toBeDefined();
expect(json.data.pagination).toBeDefined();
expect(json.meta?.requestId).toBeDefined();
});
After
import { isSuccessResponse } from '@/lib/api';
test('GET returns items', async () => {
const response = await GET(request, session);
const json = await response.json();
// Clear, predictable structure
expect(isSuccessResponse(json)).toBe(true);
expect(json.data.items).toBeDefined();
expect(json.data.pagination).toBeDefined();
expect(json.requestId).toBeDefined();
expect(json.timestamp).toBeDefined();
});
Migration Checklist
When migrating a route, follow these steps:
- Import response helpers:
import { apiSuccess, ApiErrors, ... } from '@/lib/api' - Replace all
NextResponse.json()success calls withapiSuccess() - Replace all error responses with
ApiErrors.*()helpers - Use
apiPaginated()for list endpoints - Use
apiCreated()for POST endpoints that create resources - Update tests to use
isSuccessResponse()andisErrorResponse() - Remove manual header setting (handled automatically)
- Ensure all responses include
requestId - Run tests to verify behavior
- Update API documentation if needed
Common Migration Patterns
Pattern 1: Simple Success Response
// Before
return NextResponse.json({ data: user }, { status: 200 });
// After
return apiSuccess(user);
Pattern 2: Error Response
// Before
return NextResponse.json({ error: 'Not found' }, { status: 404 });
// After
return ApiErrors.notFound('User', requestId);
Pattern 3: Validation Error
// Before
return NextResponse.json(
{ error: 'Validation failed', details: errors },
{ status: 400 }
);
// After
return ApiErrors.validationError(formatZodErrors(zodError), requestId);
Pattern 4: Created Resource
// Before
return NextResponse.json(
{ data: newUser },
{ status: 201, headers: { Location: `/api/users/${newUser.id}` } }
);
// After
return apiCreated(newUser, `/api/users/${newUser.id}`, { requestId });
Pattern 5: Paginated List
// Before
return NextResponse.json({
data: { items, pagination: { total, page, limit, pages, hasNext } }
});
// After
return apiPaginated(items, { page, pageSize: limit, total }, { requestId });
Next Steps
- Start with low-traffic routes to validate the approach
- Migrate high-traffic routes (like
/api/leads) for maximum impact - Update frontend code to expect standardized response format
- Add monitoring to track request IDs across the stack
- Update API documentation to reflect new response format
- Consider creating a PR template checklist for new routes
Questions?
- Check
/lib/api/response-helpers.tsfor implementation details - Review test files in
/tests/lib/api/response-helpers.test.ts - Contact the backend team for migration support