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 thrownErrorcarries 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
undefinedthree 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 |
|---|---|
|
All campaigns |
|
One campaign |
|
Paginated targets for a campaign |
|
Paginated calls |
|
Current user and workspace state |
|
Workspace |
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 |
|
Calls |
|
AI agents |
|
|
|
Contacts and people |
|
Sequences |
|
Workspace |
|
Reports |
|
Infrastructure |
|
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.