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
| Pattern | Use for |
|---|---|
| Server Actions | In-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 Components | Page-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.
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 })
}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)
}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
}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,
},
}
}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// 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' }
}
}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
})// 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 */ }id Parameterexport 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)
}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