Skip to content

Querying with the Client SDK

supabase-js’s query builder is a fluent TypeScript wrapper that builds the exact same PostgREST request you could write by hand, and every call resolves to a { data, error } object instead of throwing.

Everything you saw as a raw query string in the previous lesson has a matching method on the query builder. Pick a table with .from(), choose columns with .select(), filter with .eq() (and its siblings), and sort with .order():

const { data, error } = await supabase
.from('movies')
.select('id, title, year')
.eq('year', 2020)
.order('year', { ascending: false });

Writes work the same way — .insert(), .update(), and .delete() map to the corresponding HTTP verbs PostgREST exposes:

await supabase.from('movies').insert({ title: 'Arrival', year: 2016, director_id: 3 });
await supabase.from('movies').update({ year: 2021 }).eq('id', 1);
await supabase.from('movies').delete().eq('id', 1);

A supabase-js query never throws on a failed request — it always resolves with an object shaped { data, error }. If the request succeeds, error is null and data holds the rows. If it fails, data is null and error is populated. Because there is no exception to catch, you must check error yourself on every call, or a failed request will silently look like an empty result.

error is a PostgrestError with four fields worth knowing:

  • message — a human-readable description of what went wrong.
  • code — either a Postgres error code (e.g. a unique-constraint violation) or a PostgREST-specific code.
  • details — additional context from Postgres, when available.
  • hint — a suggestion for fixing the problem, when Postgres or PostgREST can offer one.
const { data, error } = await supabase.from('movies').select('id, title').eq('year', 2020);
if (error) {
console.error(`query failed [${error.code}]: ${error.message}`);
} else {
console.log(data);
}
Section titled “Embedding related tables through the builder”

Just like the raw REST API, the query builder can embed a related table by naming it inside select() — PostgREST still does the work by following the foreign key, the builder just gives you a typed, chainable way to ask for it:

const { data, error } = await supabase.from('movies').select('title, directors(name)');

Every filter operator from the raw REST API has a matching builder method — .eq(), .gte()/.lte(), .like()/.ilike(), .in(), and more — each one appending exactly the column=operator.value query parameter shown in the previous lesson. Run against the real agapi_movies table from that lesson:

src/agapi-filters-and-pagination.ts
// @run
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const { data: recent } = await supabase
.from('agapi_movies')
.select('title,year')
.gte('year', 2021)
.order('year');
console.log('gte 2021:', recent);
const { data: matches } = await supabase
.from('agapi_movies')
.select('title')
.ilike('title', '%things%');
console.log('ilike %things%:', matches);
const { data: inList } = await supabase
.from('agapi_movies')
.select('title,year')
.in('year', [2021, 2023]);
console.log('in [2021,2023]:', inList);
gte 2021: [ { title: 'Dune', year: 2021 }, { title: 'Barbie', year: 2023 }, { title: 'Poor Things', year: 2023 }, ... ]
ilike %things%: [ { title: 'Poor Things' } ]
in [2021,2023]: [ { title: 'Dune', year: 2021 }, { title: 'Barbie', year: 2023 }, { title: 'Poor Things', year: 2023 }, ... ]

.ilike() is the case-insensitive sibling of .like() — PostgREST’s own ilike. operator, not a client-side filter; the query still ran entirely in Postgres.

Pagination: .range() does not use the HTTP Range header

Section titled “Pagination: .range() does not use the HTTP Range header”

PostgREST supports two independent pagination mechanisms (per its own docs — docs.postgrest.org — Pagination and Count): the HTTP Range/Range-Unit request headers, and plain limit/offset query parameters. Given the name, it is easy to assume supabase-js’s .range(from, to) sends the Range header — it does not. Reading the method’s own source (postgrest-js/src/PostgrestTransformBuilder.ts, current version bundled with this course) shows exactly what it sets:

this.url.searchParams.set(keyOffset, `${from}`)
// Range is inclusive, so add 1
this.url.searchParams.set(keyLimit, `${to - from + 1}`)

.range() is named after the concept of a range, but implemented with the query-parameter pagination method, not the header one — confirmed by logging the exact request URL it builds before it is sent:

src/agapi-range-vs-header.ts
// @run
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const query = supabase.from('agapi_movies').select('id,title').order('id').range(0, 1);
// `.url` is a protected member of PostgrestBuilder — not part of the public
// API, only readable here (via a cast) as a one-off debugging trick to prove
// exactly what request .range() builds.
console.log('request URL:', (query as unknown as { url: URL }).url.toString());
const { data } = await query;
console.log('data:', data);
request URL: http://127.0.0.1:55321/rest/v1/agapi_movies?select=id%2Ctitle&order=id.asc&offset=0&limit=2
data: [ { id: 1, title: 'Dune' }, { id: 2, title: 'Barbie' } ]

No Range header anywhere — range(0, 1) (0-based, inclusive, per the method’s own docstring) became offset=0&limit=2. The HTTP Range header pagination style still exists and PostgREST still honors it (proved in the previous lesson’s Prefer: count=... requests, which used plain query-string limit), but no supabase-js method sends it — if you need it specifically (e.g. matching an existing Range-based client), you reach for .select() combined with a raw fetch, not the query builder.

Every query builder call — filters, embeds, pagination — ultimately becomes one SQL statement PostgREST hands to Postgres inside a single transaction. This local stack has pg_stat_statements enabled by default (confirmed with select * from pg_available_extensions where name = 'pg_stat_statements' — installed_version was already set, no create extension needed here), so the exact generated SQL for a real request is directly inspectable — no log_statement=all change to the shared server needed:

-- sql/agapi_check_pg_stat_statements.sql
select pg_stat_statements_reset();
scripts/agapi-curl-for-sql-inspection.sh
curl "$API_URL/rest/v1/agapi_movies?select=id,title&order=id&limit=2&offset=0" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Prefer: count=exact"
-- sql/agapi_read_pg_stat_statements.sql
select query from pg_stat_statements where query ilike '%agapi_movies%' limit 1;
WITH pgrst_source AS ( SELECT "public"."agapi_movies"."id", "public"."agapi_movies"."title" FROM "public"."agapi_movies" ORDER BY "public"."agapi_movies"."id" LIMIT $1 OFFSET $2 ) ,
pgrst_source_count AS (SELECT $3 FROM "public"."agapi_movies")
SELECT (SELECT pg_catalog.count(*) FROM pgrst_source_count) AS total_result_set,
pg_catalog.count(_postgrest_t) AS page_total,
coalesce(json_agg(_postgrest_t), $4) AS body,
nullif(current_setting($5, $6), $7) AS response_headers,
nullif(current_setting($8, $9), $10) AS response_status,
$11 AS response_inserted
FROM ( SELECT * FROM pgrst_source ) _postgrest_t

The query builder call, the raw REST URL, and this SQL are three views of the exact same request. A few things worth noticing directly in it: the real table/column names are double-quoted and schema-qualified ("public"."agapi_movies"."id", never a bare agapi_movies); values are bound as $1/$2/… parameters, not string-concatenated — the entire query language is immune to SQL injection by construction, because filter values never become SQL text; the extra pgrst_source_count CTE only appears because Prefer: count=exact was set — the response’s total_result_set requires a genuine second count(*), which is exactly the cost count=exact warned about in the previous lesson, now visible as a second CTE instead of an abstract claim. A full walkthrough of this translation — every clause, every operator — belongs to a level below what supabase-js needs day to day; see /supabase/en/supabase-internals/postgrest-request-to-sql/ for that.

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
async function recentMovies() {
const { data, error } = await supabase
.from('movies')
.select('id, title, year, directors(name)')
.eq('year', 2020)
.order('title', { ascending: true });
if (error) {
console.error('failed to load movies:', error.message);
return [];
}
return data;
}
flowchart LR
  from[".from('movies')"] --> select[".select('id, title, year')"]
  select --> eq[".eq('year', 2020)"]
  eq --> order[".order('year')"]
  order --> http["One HTTP request to PostgREST"]
  http --> result["Result: data and error"]
Building one PostgREST request from chained calls

An AI assistant, told “load page 3 of movies, 10 per page, using the Range header like PostgREST expects,” wrote this:

🐛 Spot the bug

src/paginated-movies.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
async function getPage(pageNumber: number, pageSize = 10) {
const from = (pageNumber - 1) * pageSize;
const to = from + pageSize;
const { data, error, count } = await supabase
.from('agapi_movies')
.select('id, title, year', { count: 'exact' })
.range(from, to);
if (error) throw error;
return { data, totalPages: Math.ceil((count ?? 0) / pageSize) };
}
Show the answer

Two bugs, one about the off-by-one in range(), one about a false assumption baked into the prompt itself. First: .range() is documented as 0-based and inclusive — range(from, to) requests to - from + 1 rows, not to - from. For page 3 with pageSize = 10, from is 20, and the code computes to as from + pageSize, i.e. 30 — that is 11 rows (20 through 30 inclusive), not 10, silently drifting every page’s boundary by one row across the whole paginated list. The fix is to = from + pageSize - 1. Second, and this is the “Range header” framing from the prompt itself misleading the implementation: nothing here uses the actual HTTP Range header — proved above, .range() sends limit/offset query parameters. That is not a bug in this code (the request still works correctly, off-by-one aside), but it means a comment or teammate description claiming this “uses the Range header” is simply wrong, and debugging a real Range-header-specific server behavior (a proxy or CDN rewriting it, say) by reading this client code will lead nowhere, because the header this code sends is never that one.

The query builder is a thin, typed URL-and-header assembler — nothing in it talks to Postgres directly, and nothing in it is executed until the object is await-ed (proved above: the URL was fully built and inspectable before the request fired). Every chained method — .eq(), .range(), .select() — mutates the same underlying URL object’s search params or the request’s headers, and the final await is the one moment an HTTP request actually leaves the process. This is why logging query.url.toString() before awaiting is such a useful debugging habit: it shows you, with total certainty, the exact REST request about to be sent — no guessing about what a chain of methods “probably” does.

  • The query builder is typed and chainable — at the cost of method names that do not always map obviously to wire behavior: .range() sounds like the Range header; it is not, proved above, and the only way to be sure is reading the source or logging the URL.
  • { data, error } never throws, so a forgotten if (error) fails visibly as wrong data rather than an uncaught exception — at the cost of that safety being opt-in: nothing forces the check, and a forgotten one degrades to “looks like an empty result,” which is easy to misdiagnose as “no matching rows” instead of “the request failed.”
  • Filter methods read naturally in TypeScript (.gte('year', 2021)) — at the cost of hiding that every one of them is still exactly the REST operator syntax underneath (year=gte.2021); reasoning about what a chain will actually do in Postgres benefits from being able to read both forms interchangeably, not just the TypeScript one.
  • “.range() sends the HTTP Range header, because that’s what it’s named after.” Proved false directly from the library’s own source and a logged request URL — it sends limit/offset query parameters, PostgREST’s other, separate pagination mechanism.
  • “If .select() returns data: null, there were no matching rows.” data is null on failure; zero matching rows is data: [], a non-null empty array, with error: null. Conflating the two hides real failures as “no results.”
  • “The query builder is a separate query language from raw PostgREST URLs.” It is a thin builder over the identical URL syntax from the previous lesson — every method call appends the same query string a hand-written curl request would use, provable by logging .url.toString() before awaiting.
  • “Reading a pg_stat_statements entry for a Data API request would need special server config.” Proved above: it is enabled by default on this local stack already — no log_statement=all or other shared-config change was needed to see the real generated SQL.
What shape does a supabase-js query result always have?
Why must you check the error field explicitly instead of relying on a try/catch?
Which combination filters movies to year 2020 and sorts by year descending?
How does the query builder embed a related directors table into a movies query?
Does supabase-js's .range(from, to) method send the HTTP Range request header?
A team member says a Data API request "must be using log_statement=all to show up in a query log." What does this lesson prove is often unnecessary?