API Development

Building robust API routes, server actions, and data validation patterns.

API Development

API Route Structure

Basic API Route Pattern

Next.js App Router uses the route.ts file convention for API endpoints:

// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ProductSchema } from '@/lib/validations/product'

export async function GET(request: NextRequest) {
  try {
    const supabase = await createClient()
    
    // Authentication check
    const { data: { user }, error: userError } = await supabase.auth.getUser()
    if (userError || !user) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      )
    }

    // Extract and validate query parameters
    const { searchParams } = new URL(request.url)
    const category = searchParams.get('category')
    const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100)
    const offset = Math.max(parseInt(searchParams.get('offset') || '0'), 0)

    // Build dynamic query
    let query = supabase
      .from('products')
      .select(`
        id,
        name,
        sku,
        price,
        category_id,
        categories!inner(name),
        inventory(quantity_on_hand)
      `)
      .eq('is_active', true)
      .order('created_at', { ascending: false })
      .range(offset, offset + limit - 1)

    if (category) {
      query = query.eq('category_id', category)
    }

    const { data, error, count } = await query

    if (error) {
      return NextResponse.json(
        { error: error.message },
        { status: 500 }
      )
    }

    return NextResponse.json({
      data,
      pagination: {
        offset,
        limit,
        total: count || 0,
        hasMore: (count || 0) > offset + limit
      }
    })
  } catch (error) {
    console.error('API Error:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

export async function POST(request: NextRequest) {
  try {
    const supabase = await createClient()
    
    // Authentication and authorization
    const { data: { user }, error: userError } = await supabase.auth.getUser()
    if (userError || !user) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      )
    }

    // Parse and validate request body
    const body = await request.json()
    const validation = ProductSchema.safeParse(body)
    
    if (!validation.success) {
      return NextResponse.json(
        { 
          error: 'Validation failed', 
          details: validation.error.flatten()
        },
        { status: 400 }
      )
    }

    // Create product with audit trail
    const productData = {
      ...validation.data,
      created_by: user.id,
      created_at: new Date().toISOString(),
    }

    const { data, error } = await supabase
      .from('products')
      .insert(productData)
      .select(`
        id,
        name,
        sku,
        price,
        categories!inner(name)
      `)
      .single()

    if (error) {
      if (error.code === '23505') { // Unique constraint violation
        return NextResponse.json(
          { error: 'Product with this SKU already exists' },
          { status: 409 }
        )
      }
      
      return NextResponse.json(
        { error: error.message },
        { status: 500 }
      )
    }

    return NextResponse.json({ data }, { status: 201 })
  } catch (error) {
    console.error('API Error:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Dynamic Route Parameters

Handle dynamic segments in API routes:

// app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'

interface RouteParams {
  params: { id: string }
}

export async function GET(
  request: NextRequest,
  { params }: RouteParams
) {
  try {
    const supabase = await createClient()
    const { id } = params

    // Validate UUID format
    if (!isValidUuid(id)) {
      return NextResponse.json(
        { error: 'Invalid product ID format' },
        { status: 400 }
      )
    }

    const { data, error } = await supabase
      .from('products')
      .select(`
        *,
        categories!inner(id, name),
        inventory!left(
          quantity_on_hand,
          reorder_point,
          warehouse_id,
          warehouses!inner(name, code)
        )
      `)
      .eq('id', id)
      .single()

    if (error) {
      if (error.code === 'PGRST116') { // No rows returned
        return NextResponse.json(
          { error: 'Product not found' },
          { status: 404 }
        )
      }
      
      return NextResponse.json(
        { error: error.message },
        { status: 500 }
      )
    }

    return NextResponse.json({ data })
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

export async function PUT(
  request: NextRequest,
  { params }: RouteParams
) {
  try {
    const supabase = await createClient()
    const { id } = params
    
    // Authentication check
    const user = await getCurrentUser()
    if (!user) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      )
    }

    // Validate input
    const body = await request.json()
    const validation = UpdateProductSchema.safeParse(body)
    
    if (!validation.success) {
      return NextResponse.json(
        { error: 'Validation failed', details: validation.error },
        { status: 400 }
      )
    }

    // Update with optimistic locking
    const updateData = {
      ...validation.data,
      updated_by: user.id,
      updated_at: new Date().toISOString(),
    }

    const { data, error } = await supabase
      .from('products')
      .update(updateData)
      .eq('id', id)
      .select()
      .single()

    if (error) {
      return NextResponse.json(
        { error: error.message },
        { status: 500 }
      )
    }

    return NextResponse.json({ data })
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

export async function DELETE(
  request: NextRequest,
  { params }: RouteParams
) {
  try {
    const supabase = await createClient()
    const { id } = params
    
    // Check permissions
    const user = await requireRole('admin')

    // Soft delete instead of hard delete
    const { error } = await supabase
      .from('products')
      .update({ 
        is_active: false,
        deleted_by: user.id,
        deleted_at: new Date().toISOString()
      })
      .eq('id', id)

    if (error) {
      return NextResponse.json(
        { error: error.message },
        { status: 500 }
      )
    }

    return NextResponse.json({ success: true })
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Server Actions

Server Actions provide a way to handle form submissions and mutations:

// lib/actions/products.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { CreateProductSchema } from '@/lib/validations/product'
import { getCurrentUser } from '@/lib/auth/server'

export async function createProduct(formData: FormData) {
  try {
    const supabase = await createClient()
    
    // Get current user
    const user = await getCurrentUser()
    if (!user) {
      return { error: 'Authentication required' }
    }

    // Extract and validate form data
    const rawData = {
      name: formData.get('name') as string,
      sku: formData.get('sku') as string,
      category_id: formData.get('category_id') as string,
      cost_price: parseFloat(formData.get('cost_price') as string),
      selling_price: parseFloat(formData.get('selling_price') as string),
      description: formData.get('description') as string,
    }

    const validation = CreateProductSchema.safeParse(rawData)
    if (!validation.success) {
      return { 
        error: 'Validation failed',
        fieldErrors: validation.error.flatten().fieldErrors
      }
    }

    // Create product
    const { data, error } = await supabase
      .from('products')
      .insert({
        ...validation.data,
        created_by: user.id,
      })
      .select()
      .single()

    if (error) {
      if (error.code === '23505') {
        return { error: 'A product with this SKU already exists' }
      }
      return { error: error.message }
    }

    // Revalidate cached data
    revalidatePath('/products')
    revalidateTag('products')

    return { data, success: true }
  } catch (error) {
    console.error('Server action error:', error)
    return { error: 'Failed to create product' }
  }
}

export async function updateProduct(id: string, formData: FormData) {
  try {
    const supabase = await createClient()
    const user = await getCurrentUser()
    
    if (!user) {
      return { error: 'Authentication required' }
    }

    // Validate and update
    const updateData = extractFormData(formData)
    const validation = UpdateProductSchema.safeParse(updateData)
    
    if (!validation.success) {
      return { 
        error: 'Validation failed',
        fieldErrors: validation.error.flatten().fieldErrors
      }
    }

    const { data, error } = await supabase
      .from('products')
      .update({
        ...validation.data,
        updated_by: user.id,
        updated_at: new Date().toISOString(),
      })
      .eq('id', id)
      .select()
      .single()

    if (error) {
      return { error: error.message }
    }

    revalidatePath('/products')
    revalidatePath(`/products/${id}`)
    
    return { data, success: true }
  } catch (error) {
    return { error: 'Failed to update product' }
  }
}

export async function deleteProduct(id: string) {
  try {
    const supabase = await createClient()
    const user = await requireRole('admin')

    const { error } = await supabase
      .from('products')
      .update({ 
        is_active: false,
        deleted_by: user.id,
        deleted_at: new Date().toISOString()
      })
      .eq('id', id)

    if (error) {
      return { error: error.message }
    }

    revalidatePath('/products')
    redirect('/products')
  } catch (error) {
    return { error: 'Failed to delete product' }
  }
}

// Utility function for form data extraction
function extractFormData(formData: FormData) {
  return {
    name: formData.get('name') as string,
    sku: formData.get('sku') as string,
    category_id: formData.get('category_id') as string,
    cost_price: parseFloat(formData.get('cost_price') as string),
    selling_price: parseFloat(formData.get('selling_price') as string),
    description: formData.get('description') as string,
  }
}

Data Validation

Zod Validation Schemas

Create comprehensive validation schemas:

// lib/validations/product.ts
import { z } from 'zod'

export const ProductSchema = z.object({
  name: z
    .string()
    .min(1, 'Product name is required')
    .max(255, 'Product name is too long'),
  
  sku: z
    .string()
    .min(1, 'SKU is required')
    .max(50, 'SKU is too long')
    .regex(/^[A-Z0-9-]+$/, 'SKU can only contain uppercase letters, numbers, and hyphens'),
  
  category_id: z
    .string()
    .uuid('Invalid category ID'),
  
  cost_price: z
    .number()
    .positive('Cost price must be positive')
    .max(999999.99, 'Cost price is too high'),
  
  selling_price: z
    .number()
    .positive('Selling price must be positive')
    .max(999999.99, 'Selling price is too high'),
  
  description: z
    .string()
    .max(1000, 'Description is too long')
    .optional(),
  
  barcode: z
    .string()
    .max(50, 'Barcode is too long')
    .optional(),
  
  is_active: z
    .boolean()
    .default(true),
  
  reorder_point: z
    .number()
    .int()
    .min(0, 'Reorder point must be non-negative')
    .optional(),
  
  tags: z
    .array(z.string())
    .optional(),
})

// Derived schemas
export const CreateProductSchema = ProductSchema.omit({
  is_active: true, // Handled by default
})

export const UpdateProductSchema = ProductSchema.partial()

export const ProductQuerySchema = z.object({
  category: z.string().uuid().optional(),
  search: z.string().max(100).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(10),
  offset: z.coerce.number().int().min(0).default(0),
  sort_by: z.enum(['name', 'sku', 'price', 'created_at']).default('created_at'),
  sort_order: z.enum(['asc', 'desc']).default('desc'),
  is_active: z.coerce.boolean().optional(),
})

// Type inference
export type Product = z.infer<typeof ProductSchema>
export type CreateProductInput = z.infer<typeof CreateProductSchema>
export type UpdateProductInput = z.infer<typeof UpdateProductSchema>
export type ProductQuery = z.infer<typeof ProductQuerySchema>

// Custom validation functions
export function validateSKU(sku: string): boolean {
  return /^[A-Z0-9-]+$/.test(sku)
}

export function validatePrice(price: number): boolean {
  return price > 0 && price <= 999999.99
}

Advanced Validation Patterns

// lib/validations/inventory.ts
import { z } from 'zod'

// Cross-field validation
export const InventoryAdjustmentSchema = z.object({
  product_id: z.string().uuid(),
  warehouse_id: z.string().uuid(),
  adjustment_type: z.enum(['increase', 'decrease', 'set']),
  quantity: z.number().int(),
  reason: z.string().min(1, 'Reason is required'),
  reference_number: z.string().optional(),
}).refine(
  (data) => {
    // Custom validation: decrease adjustments need negative quantities
    if (data.adjustment_type === 'decrease' && data.quantity > 0) {
      return false
    }
    return true
  },
  {
    message: 'Decrease adjustments must have negative quantities',
    path: ['quantity'],
  }
)

// Conditional validation
export const ProductVariantSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('simple'),
    price: z.number().positive(),
  }),
  z.object({
    type: z.literal('variable'),
    base_price: z.number().positive(),
    variants: z.array(z.object({
      name: z.string(),
      price_modifier: z.number(),
      sku_suffix: z.string(),
    })).min(1, 'Variable products must have at least one variant'),
  }),
])

// Async validation
export const UniqueProductSchema = ProductSchema.refine(
  async (data) => {
    const supabase = createClient()
    const { data: existing } = await supabase
      .from('products')
      .select('id')
      .eq('sku', data.sku)
      .single()
    
    return !existing
  },
  {
    message: 'A product with this SKU already exists',
    path: ['sku'],
  }
)

API Error Handling

Implement consistent error handling across APIs:

// lib/utils/api-error.ts
export class ApiError extends Error {
  constructor(
    message: string,
    public statusCode: number = 500,
    public code?: string,
    public details?: any
  ) {
    super(message)
    this.name = 'ApiError'
  }
}

export function handleApiError(error: unknown): NextResponse {
  console.error('API Error:', error)

  if (error instanceof ApiError) {
    return NextResponse.json(
      {
        error: error.message,
        code: error.code,
        details: error.details,
      },
      { status: error.statusCode }
    )
  }

  if (error instanceof z.ZodError) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        code: 'VALIDATION_ERROR',
        details: error.flatten(),
      },
      { status: 400 }
    )
  }

  // Database errors
  if (error && typeof error === 'object' && 'code' in error) {
    const dbError = error as { code: string; message: string }
    
    switch (dbError.code) {
      case '23505': // Unique constraint violation
        return NextResponse.json(
          { error: 'Resource already exists', code: 'DUPLICATE_ENTRY' },
          { status: 409 }
        )
      case '23503': // Foreign key violation
        return NextResponse.json(
          { error: 'Referenced resource not found', code: 'INVALID_REFERENCE' },
          { status: 400 }
        )
      default:
        return NextResponse.json(
          { error: 'Database error', code: 'DATABASE_ERROR' },
          { status: 500 }
        )
    }
  }

  return NextResponse.json(
    { error: 'Internal server error', code: 'INTERNAL_ERROR' },
    { status: 500 }
  )
}

// Usage in API routes
export async function POST(request: NextRequest) {
  try {
    // API logic here
  } catch (error) {
    return handleApiError(error)
  }
}

Build robust, type-safe APIs with proper validation, error handling, and consistent patterns across your application.