• Skip to main content
  • Skip to navigation
  • Skip to search
    Petunia™
    FeaturesPricingIntegrationsAboutContact
    Log inStart free trialSign up
    Loading
    Petunia™

    Reimagining customer communication for the modern business.

    Product

    • Features
    • Pricing
    • Integrations
    • Roadmap
    • What's New

    Resources

    • Help Center
    • Documentation
    • Guides
    • API Reference
    • Community
    • Support

    Company

    • About Us
    • Careers
    • Blog
    • Press
    • Contact

    © 2026 Gray Group International LLC. All rights reserved.·
    Made by gardenpatch 🌱

    Privacy PolicyTerms of ServiceCookie Policy

    Petunia™ is a trademark of Gray Group International LLC. The Petunia name, brand, product design, and content are proprietary. Unauthorized use, imitation, or copying is prohibited.

    Documentation

    API_MIGRATION_EXAMPLE

    docs/API_MIGRATION_EXAMPLE.md
    Docs homeGuidesSupport
    Quick links
    Start here
    How the docs are organized.
    Environment setup
    Configure env + run locally.
    Unified Inbox
    Inbox concepts & behavior.
    Voice AI setup
    Providers, Twilio, testing.
    Pricing model
    Source-of-truth pricing.
    Operations runbook
    How to operate safely.

    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 with apiSuccess()
    • 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() and isErrorResponse()
    • 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

    1. Start with low-traffic routes to validate the approach
    2. Migrate high-traffic routes (like /api/leads) for maximum impact
    3. Update frontend code to expect standardized response format
    4. Add monitoring to track request IDs across the stack
    5. Update API documentation to reflect new response format
    6. Consider creating a PR template checklist for new routes

    Questions?

    • Check /lib/api/response-helpers.ts for implementation details
    • Review test files in /tests/lib/api/response-helpers.test.ts
    • Contact the backend team for migration support
    On this page
    Before: Original Route (Inconsistent Responses)After: Standardized Route (Using Response Helpers)Key Improvements1. Consistent Response Format2. Simpler Code3. Better Type Safety4. Automatic Headers5. Pagination Made Easy6. Consistent Error HandlingCode Metrics ComparisonLines of CodeResponse VariantsHeader ManagementError HandlingTesting ComparisonBeforeAfterMigration ChecklistCommon Migration PatternsPattern 1: Simple Success ResponsePattern 2: Error ResponsePattern 3: Validation ErrorPattern 4: Created ResourcePattern 5: Paginated ListNext StepsQuestions?