Data layer

All server state flows through React Query hooks in lib/hooks/, one file per resource. There is no other client-side state store.

Query hook

"use client"

import { useQuery } from "@tanstack/react-query"
import { CampaignSchema } from "@/lib/schema/campaigns"

export function useCampaigns() {
  return useQuery({
    queryKey: ["campaigns"],
    queryFn: async () => {
      const response = await fetch("/api/campaigns", { cache: "no-store" })
      if (!response.ok) {
        const errorBody = await response.json().catch(() => ({}))
        throw new Error(errorBody.error ?? "Failed to load campaigns")
      }
      return CampaignSchema.array().parse(await response.json())
    },
  })
}

Three details matter:

cache: "no-store"

Stops Next.js adding a second, invisible cache in front of React Query. Two caches with different invalidation rules is the hard kind of bug.

Error body extraction

.catch) ⇒ ({} guards against a non-JSON error response, and the thrown Error carries the API’s own message so the UI can show something meaningful.

Schema.parse

Validates at the boundary. A backend change that renames or drops a field fails loudly here instead of rendering undefined three components deep.

Mutation hook

export function useCreateCampaign() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async (input: CreateCampaignInput) => {
      const response = await fetch("/api/campaigns", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(input),
      })
      if (!response.ok) {
        const errorBody = await response.json().catch(() => ({}))
        throw new Error(errorBody.error ?? "Failed to create campaign")
      }
      return CampaignSchema.parse(await response.json())
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["campaigns"] })
    },
  })
}

Query keys

Key Scope

["campaigns"]

All campaigns

["campaign", id]

One campaign

["campaignTargets", campaignId, params]

Paginated targets for a campaign

["calls", campaignId, params]

Paginated calls

["me"]

Current user and workspace state

["configs"]

Workspace configs key/value rows

Rules:

  • Keep keys stable — a changing key breaks caching silently, because React Query simply treats it as a new query and refetches forever.

  • Include all parameters that change the result, so two filter states cannot share a cache entry.

  • Invalidate every related key after a mutation. Creating a target should invalidate both ["campaignTargets", campaignId] and ["campaign", campaignId], because the campaign’s aggregate counts changed too.

Hook inventory

Area Hooks

Campaigns and targets

use-campaigns, use-targets, use-campaign-coverage, use-target-phone-numbers, use-target-emails

Calls

use-calls, use-all-calls, use-recent-calls, use-daily-calls, use-batch-call-analyze

AI agents

use-ai-agents, use-ai-agent-analysis-results, use-ai-agent-issues, use-prompt-suggestion, use-retell-voices, use-test-cases

Email

use-email-campaigns, use-email-campaign-targets, use-email-campaign-target-summary, use-email-variations, use-email-domains, use-emails

Contacts and people

use-contacts, use-sellers, use-sales-reps, use-follow-up-tasks

Sequences

use-sequence-templates, use-contact-sequences

Workspace

use-me, use-subscriptions, use-workspace-members, use-agents, use-configs

Reports

use-daily-stats, use-yearly-stats, use-daily-agent-performance-chart

Infrastructure

use-phone-numbers, use-activity, use-realtime, use-mobile

Schema-first types

// lib/schema/reports.ts — the schema is the source of truth
export const DailyStatSchema = z.object({ /* … */ })

// lib/types.ts — the type is derived, never hand-written
export type DailyStat = z.infer<typeof DailyStatSchema>

Never hand-write a type that a schema already describes. When a Prisma field changes, the schema is the one place to update, and both the API serialiser and the client type follow.

Pagination and filters

const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [filters, setFilters] = useState<FilterState>({})

const { data, isLoading } = useCampaignTargets(campaignId, { page, pageSize, ...filters })

Filtering is server-side throughout — list endpoints return { data, total, page, pageSize } and the table renders exactly what it was given. Client-side filtering of a paginated result would filter only the current page, which is worse than not filtering at all.

Some newer endpoints use page_size rather than pageSize (sequences, contact sequences, emails). The hook translates, so components see one convention.

Realtime

lib/hooks/use-realtime.ts subscribes to Supabase Realtime on the topic campaign-<campaignId>. Server-side, RetellCallEventHandler.broadcastEvent publishes to it during a call’s lifecycle. Realtime is used to prompt a refetch, not as a data source — the invalidated React Query key remains the single path to data.