Bluewoo HRMS
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.

ResourceList KeySingle 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,
})