The answer. Every Data API response is capped by the project's max rows setting (Settings → API), 1,000 by default, and a select() without paging returns the first thousand with no hint that more exist. Treat the cap as a blast-radius limit (lower it to what one screen can use) and page on purpose. count: "exact", head: true returns the total without transferring rows; range(from, to) is zero-based and inclusive on both ends; order() calls chain for multi-column sorts, and an enum column sorts in its definition order. Filters chained on the builder combine with AND; a multi-column search is one or() filter string in PostgREST syntax. Related rows come along in the same request through embedding, which follows foreign keys: select("*, comments(*)"), ordered per embedded table, !inner when the parent should only appear if the child exists.
The pattern.
const page = 2, size = 20, from = (page - 1) * size, to = from + size - 1;
let q = supabase.from("tickets").select("id, title, status, author:profiles!created_by(full_name), comments(count)", { count: "exact" })
.eq("tenant", tenant).order("status").order("created_at", { ascending: false }).range(from, to);
if (term) q = q.or(`title.ilike.%${clean(term)}%,description.ilike.%${clean(term)}%`); // clean(): strip " \ % ,
const { data, count } = await q;
const hasMore = (count ?? 0) > page * size;
alias:table!fk_column(*) disambiguate two FKs to the same table
comments!inner(*) keep only parents with at least one child
order("created_at", { referencedTable: "comments" }) order the embedded rows
Watch out.
- Sanitize search input before building
or()strings;,"\%all mean something to the parser. count: "exact"scans; on large tables use"planned"or"estimated"for the paginator and exact only where the number is shown.range()past the end returns an empty array with status 416 in some clients; computehasMorefrom the count, not from the fetched length.
Related: expose-only-an-api-schema-to-postgrest · postgrest-pre-request-function-is-your-api-middleware · realtime-postgres-changes-vs-broadcast-from-triggers