Querying and filtering data
Read, filter, sort, paginate, join, insert, update, upsert and delete rows through the SupaHost Data API with supabase-js, with the equivalent REST calls and the 1000-row limit.
supabase-js builds Data API requests for you: supabase.from('table') starts a query, and each method you chain adds a filter, a sort or a limit. Nothing is sent until you await it. This page shows the patterns you'll use most, using a bookings app with rooms, bookings and profiles tables.
Every query runs as anon or as the signed-in user, so RLS policies filter the results before any of your filters apply.
Selecting columns
// specific columns (smaller responses, less egress)
const { data, error } = await supabase.from('rooms').select('id, name, capacity')
// rename a column in the result
const { data: rooms } = await supabase.from('rooms').select('id, label:name')
Always check error. A failed query resolves with { data: null, error }; it doesn't throw.
Filters
const { data } = await supabase
.from('bookings')
.select('id, room_id, starts_at')
.eq('room_id', 3)
.gte('starts_at', '2026-11-01')
.lt('starts_at', '2026-12-01')
| Method | SQL meaning |
|---|---|
.eq(col, v) / .neq(col, v) |
= / <> |
.gt .gte .lt .lte |
> >= < <= |
.in(col, [a, b]) |
in (a, b) |
.like(col, '%x%') / .ilike(...) |
pattern match, case-sensitive / insensitive |
.is(col, null) |
is null (also true / false) |
.not(col, 'is', null) |
negate any filter |
.contains(col, v) / .containedBy(...) |
array or JSONB containment |
.textSearch(col, 'query') |
full-text search, see Full-text search |
.or('a.eq.1,b.eq.2') |
a = 1 or b = 2 |
Chained filters are combined with AND. For OR, write the conditions in PostgREST syntax inside .or():
const { data } = await supabase
.from('rooms')
.select('id, name')
.or('capacity.gte.10,has_projector.is.true')
Inside .or(), values containing commas or brackets need double quotes. See column does not exist when using or() filters.
Sorting and limiting
const { data } = await supabase
.from('bookings')
.select('id, starts_at')
.order('starts_at', { ascending: false })
.limit(10)
Pagination and the 1000-row limit
A single request returns at most 1000 rows on SupaHost. Asking for more, or not asking for a limit at all, still gives you at most 1000, without an error. Page with .range(from, to), which is zero-based and inclusive:
const pageSize = 100
const page = 2 // third page
const { data, count } = await supabase
.from('bookings')
.select('id, starts_at', { count: 'exact' })
.order('id')
.range(page * pageSize, page * pageSize + pageSize - 1)
count: 'exact' returns the total number of matching rows alongside the page. On large tables 'estimated' is much cheaper. To only count, add head: true and no rows are returned.
For deep pagination on big tables, filter by the last id you saw instead of using large offsets:
const { data } = await supabase
.from('bookings')
.select('id, starts_at')
.gt('id', lastSeenId)
.order('id')
.limit(100)
Single rows
// exactly one row, or an error
const { data: room, error } = await supabase.from('rooms').select('*').eq('id', 3).single()
// zero or one row, no error when missing
const { data: maybe } = await supabase.from('profiles').select('*').eq('id', userId).maybeSingle()
Joining related tables
If a foreign key links two tables, name the related table inside select:
// many-to-one: each booking with its room
const { data } = await supabase
.from('bookings')
.select('id, starts_at, room:rooms ( name, capacity )')
// one-to-many: each room with its bookings
const { data: busy } = await supabase
.from('rooms')
.select('name, bookings ( starts_at, ends_at )')
.gte('bookings.starts_at', '2026-11-12')
A filter on the joined table (bookings.starts_at) filters the nested list, not the parent rows. To keep only parents that have matching children, use an inner join:
const { data } = await supabase
.from('rooms')
.select('name, bookings!inner ( starts_at )')
.eq('bookings.user_id', userId)
When two foreign keys point at the same table, say which one: profiles!bookings_user_id_fkey ( display_name ). RLS applies to joined tables too, so a related row the user can't see comes back as null or is left out.
Inserting, updating and deleting
// insert one or many; .select() returns the new rows
const { data: created, error } = await supabase
.from('bookings')
.insert({ room_id: 3, starts_at: '2026-11-20T09:00:00Z', ends_at: '2026-11-20T10:00:00Z' })
.select()
.single()
// update matching rows
await supabase.from('bookings').update({ room_id: 4 }).eq('id', created!.id)
// upsert on the primary key (or another unique column)
await supabase
.from('profiles')
.upsert({ id: userId, display_name: 'Lee' }, { onConflict: 'id' })
// delete
await supabase.from('bookings').delete().eq('id', created!.id)
Always give update and delete a filter. Without one, they target every row your policies allow. RLS still applies: if a policy hides a row, the update or delete simply affects zero rows, with no error. Insert and update also need a select policy if you chain .select().
The same requests over HTTP
Each supabase-js call maps to a REST request, which is useful from other languages or for debugging:
curl "https://app-one.acme.supahost.space/rest/v1/bookings?select=id,starts_at,room:rooms(name)&room_id=eq.3&order=starts_at.desc&limit=10" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN"
Filters are query parameters (column=operator.value), and pagination can also use the Range: 0-99 header. See the PostgREST API reference and the supabase-js reference.
When a query doesn't fit
Aggregates across many rows, multi-step writes that must succeed together, or queries with window functions are easier as a database function you call with .rpc(). See Calling database functions (RPC) and Operations too complex for the client library.