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:
// lib/utils/create-procedure.ts
import { z, type ZodSchema } from 'zod';
// Base context for requests
export interface BaseContext {
headers?: Record<string, string>;
formData?: boolean;
}
// Options for creating a procedure
export interface ProcedureOptions {
apiBase: string;
}
// Create a procedure with middleware support
export function createProcedure<TContext extends BaseContext = BaseContext>(
options: ProcedureOptions
): BaseProcedure<TContext> {
// Implementation omitted for brevity
// See full implementation in github
}
2. Define Client Procedures
Set up both public and authenticated procedures:
// lib/api/client-procedures.ts
import { env } from '@/env/client';
import { getAccessToken } from '../utils/client-cookies';
import { createProcedure, type BaseContext } from '../utils/create-procedure';
// Public procedure - no authentication
export const clientPublicProcedure = createProcedure({
apiBase: env.NEXT_PUBLIC_API_URL,
});
// Auth context type
export interface AuthContext extends BaseContext {
headers: {
Authorization: string;
};
}
// Auth middleware
export const authMiddleware = (ctx: BaseContext): AuthContext => {
const token = getAccessToken();
if (!token) {
throw new Error('Authentication required');
}
return {
...ctx,
headers: {
...ctx.headers,
Authorization: `Bearer ${token}`,
},
};
};
// Private procedure - requires authentication
export const clientPrivateProcedure = clientPublicProcedure.use(authMiddleware);
3. Define Server Procedures
For server-side API calls:
// lib/api/server-procedures.ts
import 'server-only';
import { env } from '@/env/server';
import { createProcedure } from '../utils/create-procedure';
import { serverAuthMiddleware } from './server-auth-middleware';
// Public server procedure
export const serverPublicProcedure = createProcedure({
apiBase: env.API_URL,
});
// Private server procedure
export const serverPrivateProcedure = serverPublicProcedure.use(serverAuthMiddleware);
4. Define Domain Types
Create type definitions for your domain models:
// lib/api/roles/role-types.ts
import type { BaseResponse, PaginatedResponse } from '../types';
export interface Role {
id: string;
name: string;
description: string;
is_active: boolean;
permissions: RolePermission;
created_at: string;
updated_at: string;
}
export interface RolePermission {
[key: string]: string;
}
export type RoleListResponse = BaseResponse<
PaginatedResponse & {
roles: Role[];
}
>;
export type RoleResponse = BaseResponse<Role>;
5. Implement API Modules
Create domain-specific API modules:
// lib/api/roles/role-api.ts
import { z } from 'zod';
import { clientPrivateProcedure, type AuthContext } from '../client-procedures';
import { API_ENDPOINTS } from '@/config/api';
import type { RoleListResponse, RoleResponse } from './role-types';
import { getByIdSchema } from '../schemas';
import type { BaseProcedure } from '../../utils/create-procedure';
// Validation Schemas
const createRoleSchema = z.object({
name: z.string().min(1),
description: z.string().min(1),
permissions: z.array(z.string()),
is_active: z.boolean().default(false),
});
export type CreateRoleParams = z.infer<typeof createRoleSchema>;
const updateRoleSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().min(1),
permissions: z.array(z.string()),
is_active: z.boolean().default(false),
});
export type UpdateRoleParams = z.infer<typeof updateRoleSchema>;
export const getRolesParamsSchema = z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).default(10),
search: z.string().optional(),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).default('asc'),
});
export type GetRolesParams = z.infer<typeof getRolesParamsSchema>;
// Factory function for creating role API with any procedure
export function getRoleApi(procedure: BaseProcedure<AuthContext>) {
return {
list: procedure.input(getRolesParamsSchema).get<RoleListResponse>(API_ENDPOINTS.roles),
create: procedure.input(createRoleSchema).post<RoleResponse>(API_ENDPOINTS.roles),
update: procedure.input(updateRoleSchema).put<RoleResponse>(`${API_ENDPOINTS.roles}/:id`),
delete: procedure.input(getByIdSchema).delete<RoleResponse>(`${API_ENDPOINTS.roles}/:id`),
getById: procedure.input(getByIdSchema).get<RoleResponse>(`${API_ENDPOINTS.roles}/:id`),
};
}
// Default client-side API using client private procedure
export const roleApi = getRoleApi(clientPrivateProcedure);
6. Create React Query Hooks
Create React hooks for client-side data fetching:
// lib/api/roles/role-hooks.ts
import {
useMutation,
useQueryClient,
useSuspenseQuery,
type UseMutationOptions,
type UseQueryOptions,
} from '@tanstack/react-query';
import { roleApi, type CreateRoleParams, type UpdateRoleParams, type GetRolesParams } from './role-api';
import type { RoleListResponse, RoleResponse } from './role-types';
import { isErrorResponse, type ErrorResponse } from '../../utils/create-procedure';
export const useGetRoles = (
params?: GetRolesParams,
options?: Omit<UseQueryOptions<RoleListResponse, ErrorResponse>, 'queryFn' | 'queryKey'>
) => {
return useSuspenseQuery({
queryKey: ['roles', params],
queryFn: async () => {
const result = await roleApi.list(params);
if (isErrorResponse(result)) {
throw new Error(result.error);
}
return result.data;
},
...options,
});
};
export const useCreateRole = (
options?: Omit<UseMutationOptions<RoleResponse, ErrorResponse, CreateRoleParams>, 'mutationFn'>
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: CreateRoleParams) => {
const result = await roleApi.create(input);
if (isErrorResponse(result)) {
throw new Error(result.error);
}
queryClient.invalidateQueries({ queryKey: ['roles'] });
return result.data;
},
...options,
});
};
// Additional hooks for update, delete, etc.
7. Usage in Components
// app/(dashboard)/users/roles/page.tsx
import { Suspense } from 'react';
import { useGetRoles } from '@/lib/api/roles/role-hooks';
function RolesList() {
const { data } = useGetRoles();
return (
<div>
<h1>Roles</h1>
<ul>
{data.roles.map((role) => (
<li key={role.id}>{role.name}</li>
))}
</ul>
</div>
);
}
export default function RolesPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<RolesList />
</Suspense>
);
}
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.