I use Supabase a lot. The client is capable, but the query API is built around chaining, and every call ends with the same ceremony:
const { data, error } = await supabase
.from("users")
.select("*")
.eq("role", "admin")
.order("created_at", { ascending: false })
.limit(10);
if (error) throw error;
That is fine once. After the twentieth query, it is noise. Worse, a single missing .eq() can quietly return every row in the table.
I wanted something smaller and harder to misuse. That is why I built easy-supabase.
One line instead of six
The same query becomes:
const admins = await db.users.find(
{ role: "admin" },
{ sort: "-created_at", limit: 10 },
);
The mental model is always the same:
db.<table>.<verb>(where, options)
Every method returns the data directly. There is no { data, error } to destructure. If something fails, it throws an EasySupabaseError.
Writes are just as direct:
await db.users.create({ name: "Budi" }); // returns the new row
await db.users.update(1, { name: "Budi S" }); // returns updated rows
await db.users.remove(1); // returns deleted rows
No .select() after a write. No .single() after an insert. The wrapper handles the Supabase response shape so application code does not have to.
Install and setup
npm install easy-supabase @supabase/supabase-js
import { easySupabase } from "easy-supabase";
export const db = easySupabase(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
Already have a Supabase client? Wrap it instead of creating another one:
const db = easySupabase(existingClient);
If you use generated Supabase types, pass them through:
import type { Database } from "./database.types";
const db = easySupabase<Database>(url, key);
await db.todos.create({ title: "Ship the post" }); // Insert is type-checked
await db.todos.find({ done: "yes" }); // compile error: done is boolean
A plain table map works too:
type Schema = { users: User; posts: Post };
const db = easySupabase<Schema>(url, key);
Filters that read like the query
The filter object maps directly to SQL concepts:
await db.posts.find({ published: true });
await db.posts.find({ id: [1, 2, 3] }); // IN
await db.posts.find({ deleted_at: null }); // IS NULL
await db.posts.find({ views: { gt: 100 } }); // >
await db.posts.find({ age: { gte: 18, lt: 65 } }); // range
await db.posts.find({ title: { ilike: "%bali%" } }); // case-insensitive
await db.posts.find({ status: { ne: "draft" } }); // not equal
A bare array means IN. null means IS NULL. A Date becomes an ISO string automatically.
Lists and joins use the same PostgREST syntax you already know:
await db.posts.find(
{},
{
select: "id, title, author:users(name)",
sort: "-created_at",
limit: 20,
offset: 40,
},
);
Pagination stays explicit. offset requires limit, so it is harder to write a query that silently does something different from what you meant.
Safety is part of the API
The features I care about most are the ones that prevent accidents.
const ownerId = session?.user.id;
await db.projects.find({ owner_id: ownerId });
If ownerId is undefined, that call throws. It does not return every project in the database. Missing IDs fail loudly, which is exactly what should happen at a trust boundary.
The same rule applies to writes:
await db.users.update({}, { role: "admin" }); // throws
await db.users.remove({}); // throws
await db.users.remove({}, { all: true }); // explicit confirmation
findOne(undefined) also throws, because an unset variable would otherwise return the first row. findOne() with no argument still means "first row" when that is actually what you want.
These rules do not replace Row Level Security. easy-supabase is not a security layer. RLS still decides what each user can read and write. The wrapper just avoids the application-level mistakes that are easy to make before the request ever reaches Postgres.
Auth, storage, and realtime
The wrapper covers the parts of Supabase I use most:
await db.auth.signIn("a@b.com", "password");
await db.auth.signInWith("github");
await db.auth.magicLink("a@b.com");
await db.auth.signOut();
const user = await db.auth.user(); // User | null
const session = await db.auth.session(); // local session, no network
const { path, url } = await db.storage("avatars").upload("me.png", file, {
upsert: true,
});
db.storage("avatars").url(path); // public bucket
await db.storage("docs").signedUrl("invoice.pdf"); // private bucket
const off = db.messages.subscribe(
(event) => console.log(event.type, event.new),
{ event: "INSERT", where: { room_id: 5 } },
);
off(); // unsubscribe
The realtime helper supports one equality filter per subscription, matching what Supabase Realtime supports well. RLS still applies to every event.
Errors are values you can handle
All failures throw the same error type:
import { EasySupabaseError } from "easy-supabase";
try {
await db.users.create({ email });
} catch (error) {
if (error instanceof EasySupabaseError && error.code === "23505") {
return "Email already registered";
}
throw error;
}
If you prefer Go-style handling, there is a safe() helper:
import { safe } from "easy-supabase";
const [users, error] = await safe(db.users.find());
if (error) {
// handle it
}
Both styles are supported. Pick one and stay consistent.
When you need the real client
easy-supabase is a thin layer, not an ORM. Some things are intentionally left to supabase-js:
const { data, error } = await db.raw
.from("users")
.select()
.or("role.eq.admin,role.eq.owner");
db.raw is the original client. You can also use it for transactions, raw SQL, RPC, and edge functions. For tables whose names collide with the core API (auth, storage, raw, table), use db.table("auth").
If the wrapper ever gets in your way, the escape hatch is one property away. That is the whole point of keeping it small.
Built for AI-assisted coding too
One problem with a custom API is that an AI assistant does not know it. It defaults to the supabase-js chain it has seen a thousand times.
easy-supabase ships a SKILL.md file that teaches assistants the API, the safety rules, and common recipes:
npx easy-supabase skill
# → .claude/skills/easy-supabase/SKILL.md
npx easy-supabase skill --print >> AGENTS.md
The file is also available inside the package:
node_modules/easy-supabase/SKILL.md
I have been using this setup with Claude Code and other coding agents. The generated code follows the wrapper instead of mixing two query styles in the same project.
What it is not
easy-supabase is not trying to replace Supabase, and it is not an ORM. It does not manage migrations, relations, or caching. It is a smaller interface over the client you already have.
It also does not hide Postgres. If you need a complex query, write it in SQL or use db.raw. The wrapper should make the common path readable, not turn every query into an abstraction.
The current npm release is 0.1.0. Work on main adds auto-pagination (findAll, paginate), a truncation guard for find(), and direct wrappers for RPC and Edge Functions. Those will ship in 0.2.0.
Try it
npm install easy-supabase @supabase/supabase-js
If you use Supabase from TypeScript, try it on one table first. The best feedback will come from the queries you write every day, not the ones in a README.
