Five patterns you will write in most frontends, as React hooks over the generated client. They use the blog schema from Example: blog API (User, Post, Category) and type-check in a stock Vite react-ts project with strict settings. The server calls were run against a real server.
import { useEffect, useRef, useState } from "react";
import avClient, { AvClient, AvConflictError, type Post } from "./api/AvClient";
// A client that sends credentials, for the writes below.
const authed = new AvClient({
fetch: (input, init) =>
fetch(input, { ...init, headers: { ...init?.headers, Authorization: `Bearer ${localStorage.getItem("token")}` } }),
});1. Pagination
Page numbers
limit and offset are the simplest. Fetch the page and the total together:
const PAGE_SIZE = 5;
async function loadPage(page: number, search = "") {
const where = { published: true, title: { contains: search } };
const [rows, total] = await Promise.all([
avClient.Post.find.many({ where, orderBy: [{ id: "desc" }], limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
avClient.Post.find.count({ where }),
]);
return { rows, pages: Math.max(1, Math.ceil(total / PAGE_SIZE)) };
}The server caps limit at maxListLimit (250 by default; A2009 when exceeded).
Load more (cursor)
cursor needs an explicit orderBy that ends in a unique field. The cursor row is included in the result, so ask for one extra row and use it as the next cursor:
export function useInfinitePosts(pageSize = 10) {
const [posts, setPosts] = useState<readonly Post[]>([]);
const [next, setNext] = useState<number | null | undefined>(undefined); // undefined: nothing loaded yet
const [loading, setLoading] = useState(false);
async function loadMore() {
if (loading || next === null) return;
setLoading(true);
try {
const rows = await avClient.Post.find.many({
where: { published: true },
orderBy: [{ id: "asc" }],
limit: pageSize + 1,
...(next === undefined ? {} : { cursor: { id: next } }),
});
setPosts((prev) => [...prev, ...rows.slice(0, pageSize)]);
setNext(rows.length > pageSize ? rows[pageSize]!.id : null); // null: no more pages
} finally {
setLoading(false);
}
}
return { posts, loadMore, hasMore: next !== null, loading };
}Over four posts with a page size of two, this loads [1, 2] then [3, 4] and stops. A cursor page stays stable while rows are added, whereas an offset page can skip or repeat rows. See Querying.
2. Search box
Debounce the input and abort the request it supersedes. Filter on the post title or its author's name in one where:
export function useSearch(term: string) {
const [results, setResults] = useState<readonly Post[]>([]);
useEffect(() => {
if (term.trim() === "") { setResults([]); return; }
const controller = new AbortController();
const timer = setTimeout(() => {
avClient.Post.find
.many(
{
where: {
published: true,
OR: [{ title: { contains: term } }, { author: { name: { contains: term } } }],
},
limit: 8,
},
{ signal: controller.signal },
)
.then(setResults)
.catch(() => {}); // an abort rejects; ignore it
}, 250);
return () => { clearTimeout(timer); controller.abort(); };
}, [term]);
return results;
}contains is case-sensitive or not depending on your database's collation. Which filter operators a field accepts is part of the contract and can be narrowed (Exposure). AbortSignal is covered in Client options.
3. Create with a relation
Link an existing author and find-or-create a category in one call:
export function createPost(title: string, authorId: number, categoryName: string) {
return authed.Post.create.one({
data: {
title,
author: { $connect: { id: authorId } },
category: {
$connectOrCreate: { where: { name: categoryName }, create: { name: categoryName } },
},
},
include: ["category"],
});
}Real result:
{ "id": 3, "title": "Related", "published": false, "views": 0, "authorId": 2, "categoryId": 1,
"createdAt": "2026-10-06T22:44:05.584Z", "body": null, "category": { "id": 1, "name": "Tech" } }Other directives: $create, $connect, $disconnect, $set, $update, $delete, $upsert. Creating an author together with a post works the same way from the other side: data: { email, posts: { $create: { title: "nested" } } }. When the later step needs a generated id from an earlier one, use a transaction with $ref.
4. Optimistic update
Show the change immediately, send it, adopt the server's answer, and roll back on failure. Results are readonly, so build new objects:
export function usePublishToggle(initial: readonly Post[]) {
const [posts, setPosts] = useState(initial);
async function toggle(id: number) {
const before = posts;
const target = before.find((p) => p.id === id);
if (!target) return;
const published = !target.published;
setPosts(before.map((p) => (p.id === id ? { ...p, published } : p))); // optimistic
try {
const saved = await authed.Post.update.unique({ where: { id }, data: { published } });
setPosts((cur) => cur.map((p) => (p.id === id ? saved : p))); // the server's version
} catch (e) {
setPosts(before); // roll back
if (!(e instanceof AvConflictError)) throw e;
}
}
return { posts, toggle };
}For counters, send an increment rather than the new number so two clicks do not overwrite each other:
await authed.Post.update.unique({ where: { id }, data: { views: { $increment: 1 } }, select: ["id", "views"] });
// { id: 7, views: 1 }A failed call throws a typed error: AvAuthError when the guard refuses, AvNotFoundError if the row is gone, AvTransportError offline.
5. Counting badge
One cheap count, refreshed on an interval:
export function useDraftCount(everyMs = 30_000) {
const [count, setCount] = useState<number | null>(null);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
const run = () =>
avClient.Post.find.count({ where: { published: false } })
.then((n) => alive.current && setCount(n))
.catch(() => {});
run();
const timer = setInterval(run, everyMs);
return () => { alive.current = false; clearInterval(timer); };
}, [everyMs]);
return count;
}const drafts = useDraftCount();
return <a href="/drafts">Drafts {drafts !== null && <span className="badge">{drafts}</span>}</a>;Counts per category come back in one request with a $count reducer instead of one call each:
const cats = await avClient.Category.find.many({
select: ["id", "name", { posts: { select: ["$count"] } }],
});
// [{ id: 1, name: "Tech", posts: { count: 2 } }, ...]