Rust got left behind on Supabase, so I built supabase-rp
/ 11 min read
Last Updated:Two years ago I wrote a small Rust library to talk to Supabase from a hobby project. Yes, a hobby project for a hobby project.
The hobby project did not survive. The library did. It outlived every side project and every production project I built with it, and somewhere along the way it grew into 13 crates: the database API, auth, realtime, storage, Edge Functions, GraphQL, and schema generation.
So I finally asked myself the obvious question. I already use this everywhere. Why not clean it up, write real docs, and tell people it exists? This post is that. supabase-rp is an independent project, not an official Supabase SDK.
The reason it exists is simple. Supabase treats TypeScript as a first-class language, and Rust got left behind. In TypeScript, you generate types from your database schema, hand them to the client, and the fields you select decide the result type. In Rust, the usual answer for checked queries is SQLx. SQLx is a great Postgres driver. It is not a Supabase client, and on Supabase it usually skips row-level security (RLS) entirely.
I wanted the TypeScript deal in Rust: types generated from the schema, queries the compiler checks, and requests that still go through Supabase’s API with the user’s token, so RLS keeps doing its job. You can use plain queries without generating anything, or add schema generation when you want the compiler involved.
I am also an aspiring content creator, so of course there is a video. Five minutes, the whole Alice and Bob demo you’ll see bits of below:
How far behind Rust is
Supabase’s client library page lists five official libraries and six community ones. Rust is in neither table. The type generator writes TypeScript, Go, Swift, and Python.
The closest thing to an official Rust client is postgrest, the database API client. It started under Supabase’s GitHub account and now lives in the community org. Its last crates.io release is 1.6.0, from July 2023, and it hands you the HTTP response to decode yourself. I was already maintaining a fork of it, rp-postgrest, before any of the typed work started. Anything I found I needed in production went into the fork, like passing in my own HTTP client with a timeout.
Community Rust clients do exist, and I compare them near the end of this post. None of them show up in Supabase’s docs.
Just use SQLx?
For checked queries in Rust, the usual answer is SQLx. I get it. SQLx checks your SQL against a real database at compile time, and that part is great.
The catch is where the queries go. SQLx talks straight to Postgres. The connection strings Supabase gives you log in as postgres, and on Supabase, postgres has bypassrls. Your RLS policies don’t run for those queries. The only thing standing between one user and another user’s rows is the WHERE clause you remembered to write.
For the video I set up the smallest test I could think of. One notes table with RLS on, and one policy: you can only read your own notes. Alice’s note says “buy milk”. Bob’s note has his bank PIN. Then I fetched Alice’s notes with a plain SQLx query through the connection string Supabase gives you. No WHERE clause, because the policy is supposed to handle that:

Both rows. Alice now knows Bob’s PIN.
You can connect as a role without that privilege. Then the policies run, but auth.uid(), the helper that returns the user making the request, finds no user, because nothing told the database who is asking. So you set the role and the user’s JWT claims at the start of every transaction. Supabase’s own RLS test examples do exactly that, by hand. At that point you are rebuilding the part of Supabase that sits between your app and your database.
Auth, storage, realtime, and Edge Functions are separate APIs, so SQLx doesn’t cover them anyway. You end up rawdogging the database for queries and still need another client for everything else.
One query, one result
Generating a Rust struct for a database table gets you part of the way there. A query might only select two columns. Another might include a related record. The response is not necessarily the table’s full row.
I wanted generated bindings to replace manual Supabase types in Rote, a CLI for API workflows that I work on at Modiqo. Table types did not solve the query problem. My first fix was a projection macro, and it still made me declare the result type before I wrote the query. TypeScript does not need that step. The schema already described the columns and relationships. Why write the result fields again?
This is the plain query path. The column names live in a string, and the field names and types live in a struct:
#[derive(serde::Deserialize)]
struct MessageResponse {
id: i64,
body: String,
note: Option<String>,
}
let rows: Vec<MessageResponse> = client
.from("messages")
.select("id,body,note")
.eq("id", id.to_string())
.fetch()
.await?;
Nothing checks that the string, the struct, and the schema agree until the request runs.
select! removes the struct. This version uses the messages table from the schema-generation example. It assumes the build script has generated a database module from the example’s schema.
use rp_supabase_client::{Postgrest, schema::Selection, select};
use crate::database::public::tables::messages;
async fn print_message(
client: Postgrest,
id: i64,
) -> Result<(), Box<dyn std::error::Error>> {
let selected = select!(messages::Row => { id, body, note });
let rows = selected
.query(client)
.eq(messages::columns::id, &id)
.fetch()
.await?;
for row in rows {
println!("{}: {}", row.id, row.body);
if let Some(note) = row.note {
println!("Note: {note}");
}
}
Ok(())
}
print_message takes a client for PostgREST, Supabase’s database HTTP API. With a supabase_rp::Client, pass client.rest().clone(). There is no MessageResponse and no response type passed to fetch().
In this schema, body is a String and the nullable note is an Option<String>. The filter uses a generated column instead of a string containing its name. The compiler rejects a nonexistent selected field or an incompatible filter value.
Change the selection, and the result changes with it. Remove note while keeping row.note, and that field access no longer compiles.
In the repo’s example, that looks like this:
error[E0609]: no field `note` on type `&Record<i64, String, Number, _>`
--> crates/supabase-codegen-example/src/main.rs:54:20
|
54 | assert_eq!(row.note, None);
| ^^^^ unknown field
|
= note: available fields are: `id`, `body`, `amount`
The error tells you which fields you did select. No struct to go back and fix, just the one line that still reads note.
Named types still make sense for shared results and public function signatures. The client supports named projections for those. Local queries do not need a separate struct just to describe the fields they select.
Why this matters more now
So why bother polishing this now, after two years of keeping it to myself? LLM agents. They write better code when a type checker tells them they are wrong. A compile error that lists your real fields is something an agent can fix on the next try. A missing field in a JSON response at runtime is not.
TypeScript already has that loop with Supabase. It would be a shame if Rust did not get the same treatment.
The database does not need to be running to build
I generate the bindings in build.rs with rp-supabase-codegen. I did not want a separate CLI step to run before the build.
Ordinary builds need neither a running database nor database credentials. The repository’s example includes a snapshot so you can try the selection workflow before connecting to Supabase.
The compiler checks your code against the schema you captured, not whatever happens to be deployed today. After a schema change, refresh the snapshot and regenerate.
The compiler also does not know who is asking. That part stays with Supabase. Sign in, call with_access_token, and every request carries the user’s token, so RLS decides which rows come back. RLS filters rows instead of failing, so a query that compiles can still come back empty.

The generator also supports direct database introspection if that fits your build setup better. The codegen guide shows both paths and the snapshot commands.
Two years, thirteen crates
This started in June 2024 with JWT token refresh and a mock auth server for tests. Realtime came that summer, typed PostgREST errors in September, and the first six crates went to crates.io in October 2024. After that it was slow tuning while I ran it in production: presence, null cases in realtime messages, realtime surviving fatal errors. A release every couple of months, and then nothing for a while.
Over those two years, I kept reshaping the crates around what my production apps needed. In August 2026 the PostgREST error handling got rebuilt. By 24 October, the month had 91 commits on main: schema generation, select!, storage, Edge Functions, GraphQL, and the supabase-rp crate that ties them together. Today I think it has the best developer experience of any Rust Supabase client. Feature coverage is another story, and the next section covers what is still missing.
| Crate | Job | First release | Versions | All-time downloads |
|---|---|---|---|---|
rp-postgrest-error | Typed PostgREST error codes | Oct 2024 | 17 | 46,234 |
rp-postgrest | Database API client | Oct 2024 | 9 | 30,915 |
rp-supabase-client | Typed queries | Oct 2024 | 23 | 15,217 |
rp-supabase-auth | Auth with JWT refresh | Oct 2024 | 21 | 13,867 |
rp-supabase-codegen | Schema generation in build.rs | Oct 2026 | 15 | 7,565 |
rp-supabase-client-macros | select! | Oct 2026 | 11 | 7,482 |
rp-supabase-mock | Mock auth server for tests | Oct 2024 | 21 | 6,484 |
rp-supabase-realtime | Database changes, broadcast, presence | Oct 2024 | 20 | 5,488 |
supabase-rp | One entry point for all of it | Oct 2026 | 7 | 4,506 |
rp-supabase-storage | Buckets, objects, signed URLs | Oct 2026 | 7 | 4,443 |
rp-supabase-functions | Edge Function calls | Oct 2026 | 3 | 4,397 |
rp-supabase-graphql | GraphQL requests | Oct 2026 | 1 | 0 |
rp-supabase-http | Shared retry policy for HTTP | Oct 2026 | 1 | 0 |
Numbers from crates.io on 24 Oct 2026. The two newest crates came out that day, so they have no downloads yet. Downloads are not users. CI runs count too, mine included, and the error crate sits under both the database API client and the typed client, so every build of either counts for it as well.
About the name
Every crate starts with rp-. Those are my initials, Roberts Pumpurs.
I did not think far enough ahead. I never expected anyone else to depend on these crates. Then people did, and you can’t rename a crate on crates.io after the fact. So rp- stays. My initials are on your dependency tree. Sorry.
Where I use it, and where it falls short
Rote uses supabase-rp with generated schema bindings. Its registry client, the part that talks to Rote’s Supabase backend, runs 8 select! queries and 4 named projections, plus auth and storage calls.
Version 0.7.0, released on 24 October, closed most of the gaps I listed when this post first went up. Realtime reconnects. Storage has resumable uploads and image transforms. Auth has OAuth, anonymous sessions, MFA, and pluggable session stores.
Some gaps are still there. Realtime can’t replay messages sent during an outage. Storage has no S3 protocol. Edge Functions take no streamed request bodies. Your app still handles the OAuth browser redirect. Check the coverage and limits against your app before choosing it.
Other Rust clients also generate checked columns. rust_supabase_sdk does, and its typed queries return the full row type. Here the selection defines the result. The comparison guide compares it and two other clients feature by feature.
Try it
The entry point is supabase-rp on crates.io. To add the client with its full feature set:
cargo add supabase-rp --features full
That is enough for plain queries. The typed layer is the rp-supabase-client crate, which the code above imports. The codegen setup adds it, adds rp-supabase-codegen as a build dependency, and shows the build.rs that writes the bindings.
To try schema generation without connecting a database, clone the repository and run the committed-snapshot example from its root:
cargo run -p rp-supabase-codegen-example
With no live database configuration, it generates bindings and decodes fixture data. It does not make a live Supabase request. The example README also has a live scenario. Run it against a throwaway database.
There are also service examples, API docs, and an issue tracker for bugs and missing use cases.
Start with the snapshot example. Delete a field from its select! and build. There is no struct to update. Only the line that still reads the field fails.