API Design

Guide to API design patterns and best practices in ShipKit

API Design

This document outlines the API design patterns and best practices used in ShipKit.

REST API

Route Structure

// src/app/api/v1/posts/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
import { db } from '@/server/db'

const postSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string().optional(),
  published: z.boolean().default(false),
})

export async function GET(req: Request) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      return new NextResponse('Unauthorized', { status: 401 })
    }

    const { searchParams } = new URL(req.url)
    const page = parseInt(searchParams.get('page') ?? '1')
    const limit = parseInt(searchParams.get('limit') ?? '10')

    const posts = await db.post.findMany({
      take: limit,
      skip: (page - 1) * limit,
      orderBy: { createdAt: 'desc' },
      include: {
        author: {
          select: {
            name: true,
            image: true,
          },
        },
      },
    })

    return NextResponse.json(posts)
  } catch (error) {
    console.error(error)
    return new NextResponse('Internal Error', { status: 500 })
  }
}

export async function POST(req: Request) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      return new NextResponse('Unauthorized', { status: 401 })
    }

    const json = await req.json()
    const body = postSchema.parse(json)

    const post = await db.post.create({
      data: {
        ...body,
        authorId: session.user.id,
      },
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(error.issues, { status: 422 })
    }

    return new NextResponse('Internal Error', { status: 500 })
  }
}

API Versioning

// src/app/api/v2/posts/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
import { db } from '@/server/db'

const postSchemaV2 = z.object({
  title: z.string().min(1).max(255),
  content: z.string().optional(),
  published: z.boolean().default(false),
  tags: z.array(z.string()).optional(),
})

export async function POST(req: Request) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      return new NextResponse('Unauthorized', { status: 401 })
    }

    const json = await req.json()
    const body = postSchemaV2.parse(json)

    const post = await db.post.create({
      data: {
        ...body,
        authorId: session.user.id,
        tags: body.tags
          ? {
              create: body.tags.map((name) => ({ name })),
            }
          : undefined,
      },
      include: {
        tags: true,
      },
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(error.issues, { status: 422 })
    }

    return new NextResponse('Internal Error', { status: 500 })
  }
}

Server Actions

Action Structure

// src/server/actions/posts.ts
'use server'

import { revalidatePath } from 'next/cache'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
import { db } from '@/server/db'

const createPostSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string().optional(),
  published: z.boolean().default(false),
})

export async function createPost(data: z.infer<typeof createPostSchema>) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      throw new Error('Unauthorized')
    }

    const validated = createPostSchema.parse(data)

    const post = await db.post.create({
      data: {
        ...validated,
        authorId: session.user.id,
      },
    })

    revalidatePath('/dashboard/posts')
    return { data: post }
  } catch (error) {
    if (error instanceof z.ZodError) {
      return { error: error.issues }
    }

    return { error: 'Failed to create post' }
  }
}

export async function deletePost(id: string) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      throw new Error('Unauthorized')
    }

    const post = await db.post.findUnique({
      where: { id },
      select: { authorId: true },
    })

    if (!post) {
      throw new Error('Post not found')
    }

    if (post.authorId !== session.user.id) {
      throw new Error('Unauthorized')
    }

    await db.post.delete({ where: { id } })
    revalidatePath('/dashboard/posts')
    return { success: true }
  } catch (error) {
    return { error: error instanceof Error ? error.message : 'Failed to delete post' }
  }
}

Action Usage

// src/app/(app)/dashboard/posts/new/page.tsx
import { createPost } from '@/server/actions/posts'

export default function NewPostPage() {
  async function onSubmit(formData: FormData) {
    'use server'

    const title = formData.get('title') as string
    const content = formData.get('content') as string
    const published = formData.get('published') === 'true'

    const result = await createPost({ title, content, published })

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

    redirect('/dashboard/posts')
  }

  return (
    <form action={onSubmit}>
      <input type="text" name="title" required />
      <textarea name="content" />
      <input type="checkbox" name="published" value="true" />
      <button type="submit">Create Post</button>
    </form>
  )
}

Error Handling

API Errors

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

export function handleAPIError(error: unknown) {
  console.error(error)

  if (error instanceof APIError) {
    return new NextResponse(error.message, {
      status: error.statusCode,
      headers: {
        'Content-Type': 'application/json',
      },
    })
  }

  if (error instanceof z.ZodError) {
    return new NextResponse(JSON.stringify(error.issues), {
      status: 422,
      headers: {
        'Content-Type': 'application/json',
      },
    })
  }

  return new NextResponse('Internal Server Error', {
    status: 500,
    headers: {
      'Content-Type': 'application/json',
    },
  })
}

Error Responses

// src/lib/api-response.ts
interface APIResponse<T> {
  data?: T
  error?: {
    message: string
    code?: string
    details?: unknown
  }
}

export function createSuccessResponse<T>(data: T): APIResponse<T> {
  return { data }
}

export function createErrorResponse(
  message: string,
  code?: string,
  details?: unknown
): APIResponse<never> {
  return {
    error: {
      message,
      code,
      details,
    },
  }
}

Middleware

Authentication

// src/middleware.ts
import { NextResponse } from 'next/server'
import { getToken } from 'next-auth/jwt'
import { type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request })

  if (!token) {
    return new NextResponse('Unauthorized', { status: 401 })
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/api/v1/:path*', '/api/v2/:path*'],
}

Rate Limiting

// src/middleware/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { headers } from 'next/headers'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
})

export async function rateLimit() {
  const headersList = headers()
  const ip = headersList.get('x-forwarded-for') ?? 'anonymous'

  const { success, limit, reset, remaining } = await ratelimit.limit(ip)

  if (!success) {
    throw new APIError('Too Many Requests', 429)
  }

  return {
    limit,
    remaining,
    reset,
  }
}

Best Practices

Input Validation

// src/lib/validation.ts
import { z } from 'zod'

export const userSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  role: z.enum(['USER', 'ADMIN']).default('USER'),
})

export const postSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string().optional(),
  published: z.boolean().default(false),
  tags: z.array(z.string()).optional(),
})

export const commentSchema = z.object({
  content: z.string().min(1).max(1000),
  postId: z.string().cuid(),
})

Response Format

// src/lib/api-utils.ts
import { type NextResponse } from 'next/server'

interface PaginatedResponse<T> {
  data: T[]
  meta: {
    total: number
    page: number
    limit: number
    hasMore: boolean
  }
}

export function createPaginatedResponse<T>(
  data: T[],
  total: number,
  page: number,
  limit: number
): PaginatedResponse<T> {
  return {
    data,
    meta: {
      total,
      page,
      limit,
      hasMore: total > page * limit,
    },
  }
}

export function withCors(response: NextResponse) {
  response.headers.set('Access-Control-Allow-Origin', '*')
  response.headers.set(
    'Access-Control-Allow-Methods',
    'GET, POST, PUT, DELETE, OPTIONS'
  )
  response.headers.set(
    'Access-Control-Allow-Headers',
    'Content-Type, Authorization'
  )
  return response
}

Security Headers

// src/middleware/security.ts
import { type NextResponse } from 'next/server'

export function addSecurityHeaders(response: NextResponse) {
  response.headers.set(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  )
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-eval' 'unsafe-inline'"
  )
  return response
}

Testing

API Tests

// src/app/api/v1/posts/__tests__/route.test.ts
import { createMocks } from 'node-mocks-http'
import { GET, POST } from '../route'
import { db } from '@/server/db'
import { mockSession } from '@/lib/test-utils'

jest.mock('next-auth')

describe('Posts API', () => {
  beforeEach(async () => {
    await db.$reset()
  })

  describe('GET /api/v1/posts', () => {
    it('should return posts', async () => {
      const { req } = createMocks({
        method: 'GET',
      })

      mockSession({
        user: {
          id: 'user-1',
          email: '[email protected]',
        },
      })

      const response = await GET(req)
      const data = await response.json()

      expect(response.status).toBe(200)
      expect(Array.isArray(data)).toBe(true)
    })
  })

  describe('POST /api/v1/posts', () => {
    it('should create a post', async () => {
      const { req } = createMocks({
        method: 'POST',
        body: {
          title: 'Test Post',
          content: 'Test Content',
        },
      })

      mockSession({
        user: {
          id: 'user-1',
          email: '[email protected]',
        },
      })

      const response = await POST(req)
      const data = await response.json()

      expect(response.status).toBe(201)
      expect(data).toHaveProperty('id')
      expect(data.title).toBe('Test Post')
    })
  })
})

Action Tests

// src/server/actions/__tests__/posts.test.ts
import { createPost, deletePost } from '../posts'
import { db } from '@/server/db'
import { mockSession } from '@/lib/test-utils'

jest.mock('next-auth')

describe('Post Actions', () => {
  beforeEach(async () => {
    await db.$reset()
  })

  describe('createPost', () => {
    it('should create a post', async () => {
      mockSession({
        user: {
          id: 'user-1',
          email: '[email protected]',
        },
      })

      const result = await createPost({
        title: 'Test Post',
        content: 'Test Content',
      })

      expect(result.data).toBeDefined()
      expect(result.data?.title).toBe('Test Post')
    })
  })

  describe('deletePost', () => {
    it('should delete a post', async () => {
      mockSession({
        user: {
          id: 'user-1',
          email: '[email protected]',
        },
      })

      const post = await db.post.create({
        data: {
          title: 'Test Post',
          authorId: 'user-1',
        },
      })

      const result = await deletePost(post.id)
      expect(result.success).toBe(true)

      const deleted = await db.post.findUnique({
        where: { id: post.id },
      })
      expect(deleted).toBeNull()
    })
  })
})