# How to Connect Supabase with the PostgREST API

- Tool: Supabase
- Difficulty: Intermediate
- Time required: 15-20 min
- Compatibility: Supabase (all plans), PostgREST v11+, @supabase/supabase-js v2+
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
# Your API base URL
https://xyzcompany.supabase.co/rest/v1/

# Required headers for every request
apikey: your-anon-key-here
Authorization: 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.

```
# Fetch all rows from the 'todos' table
curl 'https://xyzcompany.supabase.co/rest/v1/todos?select=*' \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer YOUR_ANON_KEY"

# Fetch specific columns with a filter
curl 'https://xyzcompany.supabase.co/rest/v1/todos?select=id,task,is_complete&is_complete=eq.false' \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer YOUR_ANON_KEY"

# Order by created_at descending, limit to 10 rows
curl 'https://xyzcompany.supabase.co/rest/v1/todos?select=*&order=created_at.desc&limit=10' \
  -H "apikey: YOUR_ANON_KEY" \
  -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.

```
# Insert a single row
curl 'https://xyzcompany.supabase.co/rest/v1/todos' \
  -X POST \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer USER_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"task": "Buy groceries", "is_complete": false, "user_id": "uuid-here"}'

# Bulk insert multiple rows
curl 'https://xyzcompany.supabase.co/rest/v1/todos' \
  -X POST \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer USER_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -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.

```
# Update a specific row
curl 'https://xyzcompany.supabase.co/rest/v1/todos?id=eq.1' \
  -X PATCH \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer USER_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"is_complete": true}'

# Delete a specific row
curl 'https://xyzcompany.supabase.co/rest/v1/todos?id=eq.1' \
  -X DELETE \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer USER_JWT_TOKEN" \
  -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.

```
# Call a database function
curl 'https://xyzcompany.supabase.co/rest/v1/rpc/get_todos_by_status' \
  -X POST \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer USER_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -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.

```
const SUPABASE_URL = 'https://xyzcompany.supabase.co';
const SUPABASE_ANON_KEY = 'your-anon-key';

// Fetch todos with filters
const response = await fetch(
  `${SUPABASE_URL}/rest/v1/todos?select=id,task,is_complete&is_complete=eq.false&order=created_at.desc`,
  {
    headers: {
      'apikey': SUPABASE_ANON_KEY,
      'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
    },
  }
);
const 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 code example

File: `supabase-rest-api-client.ts`

```typescript
// Direct PostgREST API client for Supabase
// Use this when you cannot install @supabase/supabase-js

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY!;

interface RequestOptions {
  method?: string;
  body?: unknown;
  token?: string;
  headers?: Record<string, string>;
}

async function supabaseRequest(path: string, options: RequestOptions = {}) {
  const { method = 'GET', body, token, headers = {} } = options;
  const authToken = token || SUPABASE_ANON_KEY;

  const response = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
    method,
    headers: {
      'apikey': SUPABASE_ANON_KEY,
      'Authorization': `Bearer ${authToken}`,
      'Content-Type': 'application/json',
      'Prefer': 'return=representation',
      ...headers,
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Supabase API error: ${error.message} (${error.code})`);
  }

  return response.json();
}

// SELECT: Fetch all todos for the authenticated user
async function getTodos(userToken: string) {
  return supabaseRequest(
    'todos?select=id,task,is_complete&order=created_at.desc',
    { token: userToken }
  );
}

// INSERT: Create a new todo (RLS INSERT policy required)
async function createTodo(task: string, userToken: string) {
  return supabaseRequest('todos', {
    method: 'POST',
    body: { task, is_complete: false },
    token: userToken,
  });
}

// UPDATE: Mark a todo as complete (RLS UPDATE policy required)
async function completeTodo(id: number, userToken: string) {
  return supabaseRequest(`todos?id=eq.${id}`, {
    method: 'PATCH',
    body: { is_complete: true },
    token: userToken,
  });
}

// DELETE: Remove a todo (RLS DELETE policy required)
async function deleteTodo(id: number, userToken: string) {
  return supabaseRequest(`todos?id=eq.${id}`, {
    method: 'DELETE',
    token: userToken,
  });
}

// RPC: Call a database function
async function callFunction(name: string, params: unknown, userToken: string) {
  return supabaseRequest(`rpc/${name}`, {
    method: 'POST',
    body: params,
    token: userToken,
  });
}

export { getTodos, createTodo, completeTodo, deleteTodo, callFunction };
```

## Common mistakes

- **Forgetting to include both the apikey header and the Authorization header in requests** — undefined Fix: 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.
- **Getting empty arrays back from queries and assuming the table is empty when RLS is actually blocking access** — undefined Fix: Check that RLS policies exist for the table and role you are querying as. PostgREST returns empty results — not errors — when RLS denies access.
- **Using the service_role key in client-side code to bypass RLS instead of writing proper policies** — undefined Fix: Never expose the service_role key in browsers or mobile apps. Write RLS policies that grant appropriate access to the anon and authenticated roles.
- **Sending PATCH or DELETE requests without filter parameters, expecting them to affect all rows** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-connect-supabase-with-postgrest-api
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-connect-supabase-with-postgrest-api
