CtrlK
BlogDocsLog inGet started
Tessl Logo

product-factory/api-design

Apply this skill when designing, building, or reviewing API routes, Server Actions, or data fetching patterns in a Next.js + TypeScript + Drizzle application. Triggers on requests like "create an API endpoint", "add a server action", "design the API", "add pagination", "handle this request", "validate query params", or any time you are building the boundary between client and server. Use proactively — all API design decisions should follow these patterns.

87

Quality

87%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

Overview
Skills
Evals
Files

SKILL.md

name:
api-design
description:
Apply this skill when designing, building, or reviewing API routes, Server Actions, or data fetching patterns in a Next.js + TypeScript + Drizzle application. Triggers on requests like "create an API endpoint", "add a server action", "design the API", "add pagination", "handle this request", "validate query params", or any time you are building the boundary between client and server. Use proactively — all API design decisions should follow these patterns.

API Design: Next.js App Router + TypeScript + Drizzle

When to Use What

PatternUse for
Server ActionsIn-app mutations (forms, button actions). Get CSRF protection for free.
API Routes (GET)Data fetching from client components via TanStack Query.
API Routes (POST/PATCH/DELETE)Webhooks, external consumers, file uploads, streaming.
Server ComponentsPage-level data fetching — no API needed.

Prefer Server Actions for mutations and Server Components for reads. Only create API routes when you need an HTTP endpoint.


Consistent Error Response Contract

Every API route returns the same error shape. Clients should never have to guess the format.

// lib/api.ts
type ApiSuccessResponse<T> = T
type ApiErrorResponse = {
  error: string
  code: string
  details?: unknown
}

const ERROR_CODES = {
  VALIDATION_ERROR: 400,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  RATE_LIMITED: 429,
  INTERNAL_ERROR: 500,
} as const

export function errorResponse(
  message: string,
  code: keyof typeof ERROR_CODES,
  details?: unknown,
) {
  return Response.json(
    { error: message, code, details } satisfies ApiErrorResponse,
    { status: ERROR_CODES[code] },
  )
}

export function successResponse<T>(data: T, status = 200) {
  return Response.json(data, { status })
}

Input Validation — Every Endpoint

POST/PATCH — Validate Body with Zod

export async function POST(request: Request) {
  const session = await getServerSession()
  if (!session) return errorResponse('Unauthorised', 'UNAUTHORIZED')

  const body = await request.json().catch(() => null)
  const parsed = CreateUserSchema.safeParse(body)
  if (!parsed.success) {
    return errorResponse('Validation failed', 'VALIDATION_ERROR', parsed.error.issues)
  }

  const [user] = await db.insert(users).values(parsed.data).returning()
  return successResponse(user, 201)
}

GET — Validate Query Params with Zod

Never parse query params manually. parseInt(searchParams.get("page") || "1") gives you NaN on "abc".

const ListParamsSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().max(200).optional(),
  sort: z.enum(['name', 'createdAt', 'updatedAt']).default('createdAt'),
  order: z.enum(['asc', 'desc']).default('desc'),
})

export async function GET(request: Request) {
  const session = await getServerSession()
  if (!session) return errorResponse('Unauthorised', 'UNAUTHORIZED')

  const url = new URL(request.url)
  const parsed = ListParamsSchema.safeParse(Object.fromEntries(url.searchParams))
  if (!parsed.success) {
    return errorResponse('Invalid parameters', 'VALIDATION_ERROR', parsed.error.issues)
  }

  const { page, limit, search, sort, order } = parsed.data
  // ... use validated params
}

Pagination

Offset-Based — For Page Navigation

const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

type PaginatedResponse<T> = {
  data: T[]
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
    hasMore: boolean
  }
}

async function paginatedQuery<T>(
  query: /* your drizzle query */,
  params: z.infer<typeof PaginationSchema>,
): Promise<PaginatedResponse<T>> {
  const { page, limit } = params
  const offset = (page - 1) * limit

  const [data, [{ count }]] = await Promise.all([
    query.limit(limit).offset(offset),
    db.select({ count: sql<number>`count(*)` }).from(/* table */),
  ])

  return {
    data,
    pagination: {
      page,
      limit,
      total: count,
      totalPages: Math.ceil(count / limit),
      hasMore: page * limit < count,
    },
  }
}

Cursor-Based — For Infinite Scroll

Use cursor pagination when list order can change or for real-time feeds.

const CursorSchema = z.object({
  cursor: z.string().uuid().optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

type CursorResponse<T> = {
  data: T[]
  nextCursor: string | null
}

// Query with cursor
const items = await db
  .select()
  .from(posts)
  .where(cursor ? gt(posts.id, cursor) : undefined)
  .orderBy(asc(posts.id))
  .limit(limit + 1) // fetch one extra to check if there's more

const hasMore = items.length > limit
const data = hasMore ? items.slice(0, -1) : items
const nextCursor = hasMore ? data[data.length - 1].id : null

Server Actions — Typed Result Pattern

// features/users/actions.ts
"use server"

type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string }

export async function createUser(input: unknown): Promise<ActionResult<User>> {
  const session = await getServerSession()
  if (!session) return { success: false, error: 'Unauthorised' }

  const parsed = UserInsertSchema.safeParse(input)
  if (!parsed.success) return { success: false, error: parsed.error.issues[0].message }

  try {
    const [user] = await db.insert(users).values(parsed.data).returning()
    revalidatePath('/users')
    return { success: true, data: user }
  } catch (error) {
    logger.error('createUser failed', { error })
    return { success: false, error: 'Failed to create user' }
  }
}

Request Size Limits

Protect against oversized payloads:

// next.config.ts — for API routes
export const config = {
  api: {
    bodyParser: {
      sizeLimit: '1mb',
    },
  },
}

// For Server Actions, validate string lengths in your Zod schemas
const ContentSchema = z.object({
  body: z.string().max(50_000), // 50KB of text
})

API Route Structure

Single Resource Pattern

// app/api/users/route.ts — collection
export async function GET(request: Request) { /* list */ }
export async function POST(request: Request) { /* create */ }

// app/api/users/[id]/route.ts — individual
export async function GET(request: Request, { params }: { params: { id: string } }) { /* get one */ }
export async function PATCH(request: Request, { params }: { params: { id: string } }) { /* update */ }
export async function DELETE(request: Request, { params }: { params: { id: string } }) { /* delete */ }

Always Validate the id Parameter

export async function GET(request: Request, { params }: { params: { id: string } }) {
  const id = z.string().uuid().safeParse(params.id)
  if (!id.success) return errorResponse('Invalid ID', 'VALIDATION_ERROR')

  const [item] = await db.select().from(items).where(eq(items.id, id.data))
  if (!item) return errorResponse('Not found', 'NOT_FOUND')

  return successResponse(item)
}

Typed Client-Side Fetching

Share types between API routes and client hooks.

// features/users/types.ts — shared between server and client
export type UserListResponse = PaginatedResponse<UserListItem>
export type UserDetailResponse = User & { posts: Post[] }

// features/users/hooks/useUsers.ts
export function useUsers(params: ListParams) {
  return useQuery({
    queryKey: userKeys.list(params),
    queryFn: async (): Promise<UserListResponse> => {
      const res = await fetch(`/api/users?${new URLSearchParams(params as any)}`)
      if (!res.ok) throw new Error('Failed to fetch users')
      return res.json()
    },
  })
}

For full type safety across the boundary, consider tRPC or a shared Zod schema that both the API route and client infer from.

Install with Tessl CLI

npx tessl i product-factory/api-design

SKILL.md

tile.json