"Building Type-Safe API Clients with the Procedure Pattern"
,
The procedure pattern provides a type-safe, declarative approach to building API clients in TypeScript applications. This pattern simplifies API calls while ensuring type safety throughout your application.
Core Concepts
-
Procedures - Base objects that define the API context and middleware chain
-
Endpoints - Type-safe functions created from procedures for specific API operations
-
Middleware - Functions that modify the request context before execution
Architecture Diagram

Request Flow Sequence

Implementation
1. Create the Base Procedure Utility
First, create a utility for building procedures:
1// lib/utils/create-procedure.ts 2import { z, type ZodSchema } from 'zod'; 3 4// Base context for requests 5export interface BaseContext { 6 headers?: Record<string, string>; 7 formData?: boolean; 8} 9 10// Options for creating a procedure 11export interface ProcedureOptions { 12 apiBase: string; 13} 14 15// Create a procedure with middleware support 16export function createProcedure<TContext extends BaseContext = BaseContext>( 17 options: ProcedureOptions 18): BaseProcedure<TContext> { 19 // Implementation omitted for brevity 20 // See full implementation in github 21} 22
š Full code
2. Define Client Procedures
Set up both public and authenticated procedures:
1// lib/api/client-procedures.ts 2import { env } from '@/env/client'; 3import { getAccessToken } from '../utils/client-cookies'; 4import { createProcedure, type BaseContext } from '../utils/create-procedure'; 5 6// Public procedure - no authentication 7export const clientPublicProcedure = createProcedure({ 8 apiBase: env.NEXT_PUBLIC_API_URL, 9}); 10 11// Auth context type 12export interface AuthContext extends BaseContext { 13 headers: { 14 Authorization: string; 15 }; 16} 17 18// Auth middleware 19export const authMiddleware = (ctx: BaseContext): AuthContext => { 20 const token = getAccessToken(); 21 22 if (!token) { 23 throw new Error('Authentication required'); 24 } 25 26 return { 27 ...ctx, 28 headers: { 29 ...ctx.headers, 30 Authorization: `Bearer ${token}`, 31 }, 32 }; 33}; 34 35// Private procedure - requires authentication 36export const clientPrivateProcedure = clientPublicProcedure.use(authMiddleware); 37
3. Define Server Procedures
For server-side API calls:
1// lib/api/server-procedures.ts 2import 'server-only'; 3import { env } from '@/env/server'; 4import { createProcedure } from '../utils/create-procedure'; 5import { serverAuthMiddleware } from './server-auth-middleware'; 6 7// Public server procedure 8export const serverPublicProcedure = createProcedure({ 9 apiBase: env.API_URL, 10}); 11 12// Private server procedure 13export const serverPrivateProcedure = serverPublicProcedure.use(serverAuthMiddleware); 14
4. Define Domain Types
Create type definitions for your domain models:
1// lib/api/roles/role-types.ts 2import type { BaseResponse, PaginatedResponse } from '../types'; 3 4export interface Role { 5 id: string; 6 name: string; 7 description: string; 8 is_active: boolean; 9 permissions: RolePermission; 10 created_at: string; 11 updated_at: string; 12} 13 14export interface RolePermission { 15 [key: string]: string; 16} 17 18export type RoleListResponse = BaseResponse< 19 PaginatedResponse & { 20 roles: Role[]; 21 } 22>; 23export type RoleResponse = BaseResponse<Role>; 24
5. Implement API Modules
Create domain-specific API modules:
1// lib/api/roles/role-api.ts 2import { z } from 'zod'; 3import { clientPrivateProcedure, type AuthContext } from '../client-procedures'; 4import { API_ENDPOINTS } from '@/config/api'; 5import type { RoleListResponse, RoleResponse } from './role-types'; 6import { getByIdSchema } from '../schemas'; 7import type { BaseProcedure } from '../../utils/create-procedure'; 8 9// Validation Schemas 10const createRoleSchema = z.object({ 11 name: z.string().min(1), 12 description: z.string().min(1), 13 permissions: z.array(z.string()), 14 is_active: z.boolean().default(false), 15}); 16export type CreateRoleParams = z.infer<typeof createRoleSchema>; 17 18const updateRoleSchema = z.object({ 19 id: z.string().min(1), 20 name: z.string().min(1), 21 description: z.string().min(1), 22 permissions: z.array(z.string()), 23 is_active: z.boolean().default(false), 24}); 25export type UpdateRoleParams = z.infer<typeof updateRoleSchema>; 26 27export const getRolesParamsSchema = z.object({ 28 page: z.number().min(1).default(1), 29 limit: z.number().min(1).default(10), 30 search: z.string().optional(), 31 sortBy: z.string().optional(), 32 sortOrder: z.enum(['asc', 'desc']).default('asc'), 33}); 34export type GetRolesParams = z.infer<typeof getRolesParamsSchema>; 35 36// Factory function for creating role API with any procedure 37export function getRoleApi(procedure: BaseProcedure<AuthContext>) { 38 return { 39 list: procedure.input(getRolesParamsSchema).get<RoleListResponse>(API_ENDPOINTS.roles), 40 create: procedure.input(createRoleSchema).post<RoleResponse>(API_ENDPOINTS.roles), 41 update: procedure.input(updateRoleSchema).put<RoleResponse>(`${API_ENDPOINTS.roles}/:id`), 42 delete: procedure.input(getByIdSchema).delete<RoleResponse>(`${API_ENDPOINTS.roles}/:id`), 43 getById: procedure.input(getByIdSchema).get<RoleResponse>(`${API_ENDPOINTS.roles}/:id`), 44 }; 45} 46 47// Default client-side API using client private procedure 48export const roleApi = getRoleApi(clientPrivateProcedure); 49
6. Create React Query Hooks
Create React hooks for client-side data fetching:
1// lib/api/roles/role-hooks.ts 2import { 3 useMutation, 4 useQueryClient, 5 useSuspenseQuery, 6 type UseMutationOptions, 7 type UseQueryOptions, 8} from '@tanstack/react-query'; 9import { roleApi, type CreateRoleParams, type UpdateRoleParams, type GetRolesParams } from './role-api'; 10import type { RoleListResponse, RoleResponse } from './role-types'; 11import { isErrorResponse, type ErrorResponse } from '../../utils/create-procedure'; 12 13export const useGetRoles = ( 14 params?: GetRolesParams, 15 options?: Omit<UseQueryOptions<RoleListResponse, ErrorResponse>, 'queryFn' | 'queryKey'> 16) => { 17 return useSuspenseQuery({ 18 queryKey: ['roles', params], 19 queryFn: async () => { 20 const result = await roleApi.list(params); 21 if (isErrorResponse(result)) { 22 throw new Error(result.error); 23 } 24 return result.data; 25 }, 26 ...options, 27 }); 28}; 29 30export const useCreateRole = ( 31 options?: Omit<UseMutationOptions<RoleResponse, ErrorResponse, CreateRoleParams>, 'mutationFn'> 32) => { 33 const queryClient = useQueryClient(); 34 35 return useMutation({ 36 mutationFn: async (input: CreateRoleParams) => { 37 const result = await roleApi.create(input); 38 if (isErrorResponse(result)) { 39 throw new Error(result.error); 40 } 41 queryClient.invalidateQueries({ queryKey: ['roles'] }); 42 return result.data; 43 }, 44 ...options, 45 }); 46}; 47 48// Additional hooks for update, delete, etc. 49
7. Usage in Components
1// app/(dashboard)/users/roles/page.tsx 2import { Suspense } from 'react'; 3import { useGetRoles } from '@/lib/api/roles/role-hooks'; 4 5function RolesList() { 6 const { data } = useGetRoles(); 7 8 return ( 9 <div> 10 <h1>Roles</h1> 11 <ul> 12 {data.roles.map((role) => ( 13 <li key={role.id}>{role.name}</li> 14 ))} 15 </ul> 16 </div> 17 ); 18} 19 20export default function RolesPage() { 21 return ( 22 <Suspense fallback={<div>Loading...</div>}> 23 <RolesList /> 24 </Suspense> 25 ); 26} 27
Benefits
-
Type Safety: Full type inference from API definitions to UI components
-
Code Organization: Modular API modules with consistent patterns
-
Middleware Support: Easy addition of authentication, logging, or error handling
-
Reusability: Server and client can share type definitions and API structures
-
Validation: Input validation with Zod ensures data integrity
The procedure pattern provides a clean architecture for your API layer while maintaining type safety throughout your Next.js application.