AI Development GuideDevelopment
Frontend API Patterns
Copy-paste React Query patterns for frontend-backend integration
Frontend API Patterns
This document provides copy-paste patterns for integrating the Next.js frontend with the NestJS API using TanStack Query (React Query v5).
Pattern Enforcement
Copy these patterns EXACTLY. Do not modify the structure or add "improvements".
API Client Setup
Base Client (lib/api/client.ts)
import { getSession } from 'next-auth/react'
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
interface ApiResponse<T> {
data: T | null
error: { code: string; message: string } | null
}
class ApiClient {
private async getHeaders(): Promise<HeadersInit> {
const session = await getSession()
return {
'Content-Type': 'application/json',
'x-tenant-id': session?.user?.tenantId ?? '',
'x-system-role': session?.user?.systemRole ?? '',
}
}
async get<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${API_URL}${path}`)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, value)
}
})
}
const res = await fetch(url.toString(), {
method: 'GET',
headers: await this.getHeaders(),
})
const json: ApiResponse<T> = await res.json()
if (json.error) {
throw new Error(json.error.message)
}
return json.data as T
}
async post<T>(path: string, data: unknown): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
method: 'POST',
headers: await this.getHeaders(),
body: JSON.stringify(data),
})
const json: ApiResponse<T> = await res.json()
if (json.error) {
throw new Error(json.error.message)
}
return json.data as T
}
async patch<T>(path: string, data: unknown): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
method: 'PATCH',
headers: await this.getHeaders(),
body: JSON.stringify(data),
})
const json: ApiResponse<T> = await res.json()
if (json.error) {
throw new Error(json.error.message)
}
return json.data as T
}
async delete<T>(path: string): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
method: 'DELETE',
headers: await this.getHeaders(),
})
const json: ApiResponse<T> = await res.json()
if (json.error) {
throw new Error(json.error.message)
}
return json.data as T
}
}
export const apiClient = new ApiClient()Query Provider Setup
Provider (providers/query-provider.tsx)
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}Hook Patterns
List Query Hook (Read Many)
// hooks/use-employees.ts
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api/client'
export interface Employee {
id: string
firstName: string
lastName: string
email: string
status: 'ACTIVE' | 'INACTIVE' | 'ON_LEAVE' | 'TERMINATED'
}
export interface EmployeeFilters {
status?: string
departmentId?: string
search?: string
page?: number
limit?: number
}
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
limit: number
totalPages: number
}
export function useEmployees(filters?: EmployeeFilters) {
return useQuery({
queryKey: ['employees', filters],
queryFn: () =>
apiClient.get<PaginatedResponse<Employee>>('/api/v1/employees', {
status: filters?.status,
departmentId: filters?.departmentId,
search: filters?.search,
page: filters?.page?.toString(),
limit: filters?.limit?.toString(),
}),
})
}Single Item Query Hook (Read One)
// hooks/use-employee.ts
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api/client'
import type { Employee } from './use-employees'
export function useEmployee(id: string | undefined) {
return useQuery({
queryKey: ['employees', id],
queryFn: () => apiClient.get<Employee>(`/api/v1/employees/${id}`),
enabled: !!id, // Only fetch if id exists
})
}Create Mutation Hook
// hooks/use-create-employee.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '@/lib/api/client'
import type { Employee } from './use-employees'
export interface CreateEmployeeDto {
firstName: string
lastName: string
email: string
departmentId?: string
}
export function useCreateEmployee() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateEmployeeDto) =>
apiClient.post<Employee>('/api/v1/employees', data),
onSuccess: () => {
// Invalidate list queries to refetch
queryClient.invalidateQueries({ queryKey: ['employees'] })
},
})
}Update Mutation Hook
// hooks/use-update-employee.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '@/lib/api/client'
import type { Employee } from './use-employees'
export interface UpdateEmployeeDto {
firstName?: string
lastName?: string
email?: string
status?: string
}
export function useUpdateEmployee(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateEmployeeDto) =>
apiClient.patch<Employee>(`/api/v1/employees/${id}`, data),
onSuccess: (updatedEmployee) => {
// Update the single item cache
queryClient.setQueryData(['employees', id], updatedEmployee)
// Invalidate list queries
queryClient.invalidateQueries({ queryKey: ['employees'] })
},
})
}Delete Mutation Hook
// hooks/use-delete-employee.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '@/lib/api/client'
export function useDeleteEmployee() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiClient.delete<void>(`/api/v1/employees/${id}`),
onSuccess: (_, deletedId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: ['employees', deletedId] })
// Invalidate list queries
queryClient.invalidateQueries({ queryKey: ['employees'] })
},
})
}Component Patterns
List Component with Loading/Error States
'use client'
import { useEmployees } from '@/hooks/use-employees'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
export function EmployeeList() {
const { data, error, isLoading } = useEmployees()
if (isLoading) {
return (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)
}
if (error) {
return (
<Alert variant="destructive">
<AlertDescription>
Failed to load employees: {error.message}
</AlertDescription>
</Alert>
)
}
if (!data?.items?.length) {
return <p className="text-muted-foreground">No employees found.</p>
}
return (
<ul className="space-y-2">
{data.items.map((employee) => (
<li key={employee.id} className="p-4 border rounded-lg">
{employee.firstName} {employee.lastName}
</li>
))}
</ul>
)
}Form with Mutation
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useCreateEmployee } from '@/hooks/use-create-employee'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
const schema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email'),
})
type FormData = z.infer<typeof schema>
export function CreateEmployeeForm({ onSuccess }: { onSuccess?: () => void }) {
const { mutate, isPending } = useCreateEmployee()
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
},
})
const onSubmit = (data: FormData) => {
mutate(data, {
onSuccess: () => {
toast.success('Employee created successfully')
form.reset()
onSuccess?.()
},
onError: (error) => {
toast.error(error.message)
},
})
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Input
placeholder="First name"
{...form.register('firstName')}
/>
{form.formState.errors.firstName && (
<p className="text-sm text-destructive">
{form.formState.errors.firstName.message}
</p>
)}
<Input
placeholder="Last name"
{...form.register('lastName')}
/>
{form.formState.errors.lastName && (
<p className="text-sm text-destructive">
{form.formState.errors.lastName.message}
</p>
)}
<Input
type="email"
placeholder="Email"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
<Button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Employee'}
</Button>
</form>
)
}Query Key Conventions
Consistent query keys enable proper cache invalidation.
| Resource | List Key | Single Key |
|---|---|---|
| Employees | ['employees', filters] | ['employees', id] |
| Departments | ['departments', filters] | ['departments', id] |
| Teams | ['teams', filters] | ['teams', id] |
| Time-off requests | ['timeoff-requests', filters] | ['timeoff-requests', id] |
| Documents | ['documents', filters] | ['documents', id] |
Invalidation Pattern
// Invalidate all employee queries
queryClient.invalidateQueries({ queryKey: ['employees'] })
// Invalidate specific employee
queryClient.invalidateQueries({ queryKey: ['employees', specificId] })
// Invalidate with exact match
queryClient.invalidateQueries({
queryKey: ['employees', { status: 'ACTIVE' }],
exact: true,
})Related Documentation
- API Reference - Endpoint specifications
- API Response Format - Response envelope details
- Error Handling - Error patterns
- Frontend Architecture - TanStack Query overview