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.