Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Connect Supabase with the PostgREST API

Supabase auto-generates a RESTful API from your PostgreSQL schema using PostgREST. You can query it directly via HTTP requests using your project URL and anon key, without the JS client library. Every table in the public schema gets instant CRUD endpoints that respect Row Level Security policies, letting you integrate Supabase with any language or platform that can make HTTP calls.

What you'll learn

  • How PostgREST automatically generates REST endpoints from your database schema
  • How to make direct HTTP requests to the Supabase REST API with proper authentication
  • How to perform filtering, pagination, and ordering via query parameters
  • How RLS policies apply to direct API requests the same way as the JS client
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate9 min read15-20 minSupabase (all plans), PostgREST v11+, @supabase/supabase-js v2+March 2026RapidDev Engineering Team
TL;DR

Supabase auto-generates a RESTful API from your PostgreSQL schema using PostgREST. You can query it directly via HTTP requests using your project URL and anon key, without the JS client library. Every table in the public schema gets instant CRUD endpoints that respect Row Level Security policies, letting you integrate Supabase with any language or platform that can make HTTP calls.

Understanding the Supabase REST API Powered by PostgREST

Every Supabase project includes PostgREST, a Haskell-based server that reads your PostgreSQL schema and generates a RESTful API automatically. When you use the Supabase JS client to call supabase.from('todos').select('*'), it translates that into an HTTP request to the PostgREST endpoint behind the scenes. This tutorial teaches you how to call the API directly using HTTP, which is essential when integrating Supabase with backend services, mobile apps, or languages without an official Supabase SDK.

Prerequisites

  • A Supabase project with at least one table in the public schema
  • Your project URL and anon key from Dashboard → Settings → API
  • Basic understanding of REST APIs and HTTP methods
  • A tool for making HTTP requests (curl, Postman, or any HTTP client)

Step-by-step guide

1

Find your PostgREST API URL and authentication keys

Every Supabase project exposes its REST API at https://<project-ref>.supabase.co/rest/v1/. Open your Supabase Dashboard, go to Settings → API, and copy the Project URL and the anon (public) key. The anon key is safe to use in client-side code because it maps to the Postgres anon role, which is restricted by your RLS policies. For server-side operations that need to bypass RLS, use the service_role key — but never expose it in client code.

typescript
1# Your API base URL
2https://xyzcompany.supabase.co/rest/v1/
3
4# Required headers for every request
5apikey: your-anon-key-here
6Authorization: Bearer your-anon-key-here

Expected result: You have your project URL and API keys ready to make direct HTTP requests.

2

Make a SELECT request to read data from a table

To read data, send a GET request to your API URL followed by the table name. PostgREST maps GET requests to SQL SELECT statements. You can add query parameters for filtering: ?column_name=eq.value for equality, ?select=col1,col2 for column selection, and ?order=column.asc for sorting. The response is always a JSON array of objects. If RLS is enabled and no SELECT policy exists, you will get an empty array — not an error.

typescript
1# Fetch all rows from the 'todos' table
2curl 'https://xyzcompany.supabase.co/rest/v1/todos?select=*' \
3 -H "apikey: YOUR_ANON_KEY" \
4 -H "Authorization: Bearer YOUR_ANON_KEY"
5
6# Fetch specific columns with a filter
7curl 'https://xyzcompany.supabase.co/rest/v1/todos?select=id,task,is_complete&is_complete=eq.false' \
8 -H "apikey: YOUR_ANON_KEY" \
9 -H "Authorization: Bearer YOUR_ANON_KEY"
10
11# Order by created_at descending, limit to 10 rows
12curl 'https://xyzcompany.supabase.co/rest/v1/todos?select=*&order=created_at.desc&limit=10' \
13 -H "apikey: YOUR_ANON_KEY" \
14 -H "Authorization: Bearer YOUR_ANON_KEY"

Expected result: A JSON array of matching rows is returned. If the table has RLS enabled, only rows permitted by your SELECT policy appear.

3

Insert data with a POST request

To insert data, send a POST request with a JSON body containing the row data. Set the Content-Type header to application/json. PostgREST maps POST to SQL INSERT. You can insert a single object or an array of objects for bulk inserts. To get the inserted row back in the response, add the header Prefer: return=representation. Remember that RLS INSERT policies must allow the operation, and you also need a SELECT policy if you want the response to include the inserted data.

typescript
1# Insert a single row
2curl 'https://xyzcompany.supabase.co/rest/v1/todos' \
3 -X POST \
4 -H "apikey: YOUR_ANON_KEY" \
5 -H "Authorization: Bearer USER_JWT_TOKEN" \
6 -H "Content-Type: application/json" \
7 -H "Prefer: return=representation" \
8 -d '{"task": "Buy groceries", "is_complete": false, "user_id": "uuid-here"}'
9
10# Bulk insert multiple rows
11curl 'https://xyzcompany.supabase.co/rest/v1/todos' \
12 -X POST \
13 -H "apikey: YOUR_ANON_KEY" \
14 -H "Authorization: Bearer USER_JWT_TOKEN" \
15 -H "Content-Type: application/json" \
16 -H "Prefer: return=representation" \
17 -d '[{"task": "Task 1"}, {"task": "Task 2"}]'

Expected result: The inserted row(s) are returned as JSON when using Prefer: return=representation. Without that header, you get a 201 Created with no body.

4

Update and delete data with PATCH and DELETE requests

Use PATCH for updates and DELETE for deletions. Both require query parameters to identify which rows to affect — without filters, PostgREST will refuse the operation for safety. Use the same filter syntax as SELECT: ?id=eq.1 for a specific row, or combine multiple filters. RLS UPDATE policies must allow the operation, and UPDATE also requires a matching SELECT policy. RLS DELETE policies control which rows can be removed.

typescript
1# Update a specific row
2curl 'https://xyzcompany.supabase.co/rest/v1/todos?id=eq.1' \
3 -X PATCH \
4 -H "apikey: YOUR_ANON_KEY" \
5 -H "Authorization: Bearer USER_JWT_TOKEN" \
6 -H "Content-Type: application/json" \
7 -H "Prefer: return=representation" \
8 -d '{"is_complete": true}'
9
10# Delete a specific row
11curl 'https://xyzcompany.supabase.co/rest/v1/todos?id=eq.1' \
12 -X DELETE \
13 -H "apikey: YOUR_ANON_KEY" \
14 -H "Authorization: Bearer USER_JWT_TOKEN" \
15 -H "Prefer: return=representation"

Expected result: The updated or deleted row(s) are returned as JSON. If RLS blocks the operation, you get an empty array or a permission error.

5

Call RPC endpoints for stored procedures

PostgREST also exposes PostgreSQL functions as RPC endpoints. Send a POST request to /rest/v1/rpc/function_name with a JSON body for the parameters. This is how supabase.rpc() works under the hood. Functions must be in the public schema and have EXECUTE permission granted to the calling role (anon or authenticated). Security definer functions bypass RLS, while security invoker functions run with the caller's permissions.

typescript
1# Call a database function
2curl 'https://xyzcompany.supabase.co/rest/v1/rpc/get_todos_by_status' \
3 -X POST \
4 -H "apikey: YOUR_ANON_KEY" \
5 -H "Authorization: Bearer USER_JWT_TOKEN" \
6 -H "Content-Type: application/json" \
7 -d '{"status_filter": "pending"}'

Expected result: The function result is returned as JSON. The format matches whatever the PostgreSQL function returns.

6

Use the API from JavaScript without the Supabase client

While the Supabase JS client is convenient, you can call the REST API directly using fetch or any HTTP library. This is useful in environments where you cannot install npm packages, or when you need fine-grained control over requests. Pass the user's JWT token in the Authorization header for authenticated requests. The API behaves identically whether called via the JS client or direct HTTP — the same RLS policies apply.

typescript
1const SUPABASE_URL = 'https://xyzcompany.supabase.co';
2const SUPABASE_ANON_KEY = 'your-anon-key';
3
4// Fetch todos with filters
5const response = await fetch(
6 `${SUPABASE_URL}/rest/v1/todos?select=id,task,is_complete&is_complete=eq.false&order=created_at.desc`,
7 {
8 headers: {
9 'apikey': SUPABASE_ANON_KEY,
10 'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
11 },
12 }
13);
14const todos = await response.json();

Expected result: You receive the same JSON response as when using the Supabase JS client, with RLS applied based on the Authorization token.

Complete working example

supabase-rest-api-client.ts
1// Direct PostgREST API client for Supabase
2// Use this when you cannot install @supabase/supabase-js
3
4const SUPABASE_URL = process.env.SUPABASE_URL!;
5const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY!;
6
7interface RequestOptions {
8 method?: string;
9 body?: unknown;
10 token?: string;
11 headers?: Record<string, string>;
12}
13
14async function supabaseRequest(path: string, options: RequestOptions = {}) {
15 const { method = 'GET', body, token, headers = {} } = options;
16 const authToken = token || SUPABASE_ANON_KEY;
17
18 const response = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
19 method,
20 headers: {
21 'apikey': SUPABASE_ANON_KEY,
22 'Authorization': `Bearer ${authToken}`,
23 'Content-Type': 'application/json',
24 'Prefer': 'return=representation',
25 ...headers,
26 },
27 body: body ? JSON.stringify(body) : undefined,
28 });
29
30 if (!response.ok) {
31 const error = await response.json();
32 throw new Error(`Supabase API error: ${error.message} (${error.code})`);
33 }
34
35 return response.json();
36}
37
38// SELECT: Fetch all todos for the authenticated user
39async function getTodos(userToken: string) {
40 return supabaseRequest(
41 'todos?select=id,task,is_complete&order=created_at.desc',
42 { token: userToken }
43 );
44}
45
46// INSERT: Create a new todo (RLS INSERT policy required)
47async function createTodo(task: string, userToken: string) {
48 return supabaseRequest('todos', {
49 method: 'POST',
50 body: { task, is_complete: false },
51 token: userToken,
52 });
53}
54
55// UPDATE: Mark a todo as complete (RLS UPDATE policy required)
56async function completeTodo(id: number, userToken: string) {
57 return supabaseRequest(`todos?id=eq.${id}`, {
58 method: 'PATCH',
59 body: { is_complete: true },
60 token: userToken,
61 });
62}
63
64// DELETE: Remove a todo (RLS DELETE policy required)
65async function deleteTodo(id: number, userToken: string) {
66 return supabaseRequest(`todos?id=eq.${id}`, {
67 method: 'DELETE',
68 token: userToken,
69 });
70}
71
72// RPC: Call a database function
73async function callFunction(name: string, params: unknown, userToken: string) {
74 return supabaseRequest(`rpc/${name}`, {
75 method: 'POST',
76 body: params,
77 token: userToken,
78 });
79}
80
81export { getTodos, createTodo, completeTodo, deleteTodo, callFunction };

Common mistakes when connecting Supabase with the PostgREST API

Why it's a problem: Forgetting to include both the apikey header and the Authorization header in requests

How to avoid: Every request requires apikey for project identification and Authorization: Bearer <token> for authentication. The anon key works for both when no user is logged in.

Why it's a problem: Getting empty arrays back from queries and assuming the table is empty when RLS is actually blocking access

How to avoid: Check that RLS policies exist for the table and role you are querying as. PostgREST returns empty results — not errors — when RLS denies access.

Why it's a problem: Using the service_role key in client-side code to bypass RLS instead of writing proper policies

How to avoid: Never expose the service_role key in browsers or mobile apps. Write RLS policies that grant appropriate access to the anon and authenticated roles.

Why it's a problem: Sending PATCH or DELETE requests without filter parameters, expecting them to affect all rows

How to avoid: PostgREST requires explicit filters on PATCH and DELETE for safety. Add query parameters like ?id=eq.1 to target specific rows.

Best practices

  • Always use the anon key for client-side requests and reserve the service_role key for server-side admin operations only
  • Enable RLS on every table and write explicit policies before exposing data through the API
  • Use the Prefer: return=representation header on POST, PATCH, and DELETE to get the affected rows in the response
  • Add the Prefer: count=exact header to GET requests when you need total row counts for pagination
  • Use column-specific select parameters (?select=id,name) instead of select=* to reduce payload size
  • Index columns that appear in your RLS policies and frequently filtered query parameters
  • Test API calls with curl or Postman before implementing them in your application code
  • Pass the authenticated user's JWT in the Authorization header to ensure RLS policies apply correctly

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I want to call the Supabase REST API directly using HTTP requests instead of the JavaScript client library. Show me how to perform CRUD operations on a 'products' table with proper authentication headers, filtering, pagination, and error handling. Include examples for both anonymous and authenticated requests.

Supabase Prompt

Create a lightweight TypeScript wrapper for the Supabase PostgREST API that supports GET with filters, POST for inserts, PATCH for updates, DELETE, and RPC calls. Include proper header management for both anon and authenticated requests, and handle error responses.

Frequently asked questions

What is the difference between the Supabase JS client and the PostgREST API?

The Supabase JS client is a convenience wrapper that translates method calls like supabase.from('todos').select('*') into HTTP requests to the PostgREST API. The underlying API is identical — the JS client just provides a nicer developer experience with TypeScript types and method chaining.

Do RLS policies apply to direct API requests?

Yes. PostgREST uses the same Postgres roles (anon and authenticated) that RLS policies reference. Whether you use the JS client or direct HTTP requests, the same RLS policies are evaluated for every query.

Can I use the PostgREST API from a Python or Go backend?

Absolutely. The PostgREST API is language-agnostic. Any HTTP client in any language can call it. Use the apikey and Authorization headers exactly as you would from JavaScript.

How do I handle pagination with the PostgREST API?

Use the Range header (Range: 0-9 for the first 10 rows) or query parameters like ?limit=10&offset=20. Add the Prefer: count=exact header to get the total count in the Content-Range response header.

Why does my INSERT return an empty array instead of the inserted row?

Add the Prefer: return=representation header to your request. Without it, PostgREST returns a 201 status with no body. Also ensure you have a SELECT RLS policy, because the JS client performs a SELECT after INSERT to return the data.

Is the PostgREST API the same for self-hosted and cloud Supabase?

Yes. PostgREST is an open-source component included in both cloud and self-hosted Supabase. The API behavior, endpoints, and query syntax are identical.

Can RapidDev help integrate Supabase's REST API into my existing backend?

Yes. RapidDev can build custom API integrations between Supabase's PostgREST endpoints and your existing backend services, including proper authentication handling, error management, and RLS policy design.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.