Supabase vs CustomAPIs for multi-tenant APIs
Both are built on Postgres, and both support multi-tenancy. The difference shows up immediately: on one it has to be built, on the other it is part of the framework.
Supabase and CustomAPIs have important things in common. Both expose database-backed functionality through API routes. Both are capable platforms for real applications.
The distinction matters once one API serves many tenants. Tenant data has to stay isolated, tenancy enforcement has to stay out of the business rules, and access has to be controlled on every route and every event it emits. How much of that the platform gives you, and how much your team writes and maintains, is what this article is about.
Supabase is a broad, highly extensible platform built around a generated data API over Postgres. CustomAPIs is a framework for building multi-tenant application APIs — tenant isolation, permission scope and business operations in one layer.
You can, of course, write your own multi-tenant API layer in front of Supabase. That is a sound architecture. But then the question isn't Supabase versus anything — it's whether you build tenant scope, permission resolution, audit and events yourself or start with them, which is a separate discussion. This article compares the two platforms as you would actually adopt them.
Where the two agree
The disagreement is narrower than a comparison usually implies. Both approaches start from the same convictions:
- Standard API behaviour should be declarative. Repetitive routes should come from a data model rather than from developers hand-writing similar endpoints.
- The data model is central. Routes, validation and documentation should derive from one description of entities and their relationships.
- Relational modelling suits business systems. Organisations, users, roles, records and workflows naturally relate to one another.
- Consistency should be structural. Generated behaviour beats conventions that every developer has to remember and apply by hand.
They differ in what they carry, and how far. Supabase carries the schema into a generated data API, and provides flexible platform capabilities around it. CustomAPIs carries the model into the application API layer.
Three steps to a tenant-scoped route
In CustomAPIs, making a resource available to the right people is three steps:
Create the table
Your schema, with a foreign-key path from the record to its tenant.
Declare the route
f.route({ model: 'employees', operation: 'get' });Grant the permission
Add
app_get_employeesto a role in access management.
There is no fourth step for tenant isolation. The framework reads the foreign-key path from the record to the tenant and applies that scope to every route on the resource — the live demo answers one endpoint three ways from three keys in the same company, with no filtering code in the application.
The same steps in Supabase look like this:
| Step | Supabase | CustomAPIs |
|---|---|---|
| Create the table | Create the table | Create the table |
| Make the route available | Expose the table through the Data API, then configure grants and RLS before clients can reach the intended rows | One line per operation naming the model and the operation |
| Restrict it to the caller's tenant | Write an RLS policy expressing the path to the tenant, for each operation | Derived from the foreign-key path — no step |
| Let a user perform it | Build a roles and permissions model, then encode it in policy SQL and JWT claims | Add app_get_employees to the user's role |
The first row is identical on purpose. Neither approach removes the work of modelling the relationship — the question is how many times you have to restate it.
The same resource, both ways
Take line items on an invoice, four levels down from the tenant: organisation → project → invoice → line item. Full CRUD in CustomAPIs, against the select case alone in Supabase:
CustomAPIs — five routes
f.route({ model: 'line_items', operation: 'get' });
f.route({ model: 'line_items', operation: 'search' });
f.route({ model: 'line_items', operation: 'create' });
f.route({ model: 'line_items', operation: 'update' });
f.route({ model: 'line_items', operation: 'delete' }); Then grant app_get_line_items and its siblings to the roles that should hold them.
Supabase — the select case alone
alter table line_items enable row level security;
grant select, insert, update, delete
on line_items to authenticated;
create policy "line_items_select" on line_items
for select to authenticated
using (
exists (
select 1 from invoices i
join projects p on p.id = i.project_id
join user_organisations uo
on uo.organisation_id = p.organisation_id
where i.id = line_items.invoice_id
and uo.user_id = auth.uid()
)
); This is one policy of four. insert needs a with check, update needs both clauses, delete its own using. Teams may also factor a recurring check like this into a carefully designed security definer helper: it simplifies the policy and avoids re-planning the join for every row, but it is more code to write, index and maintain, and a helper that bypasses row security by design carries security considerations of its own.
Five lines and a role grant, against roughly forty lines of SQL and four policy tests. Per table.
Foreign key enforcement
Relationships introduce a second enforcement problem, and it starts the same way for both approaches. Postgres validates foreign keys with row security deliberately bypassed — the Postgres documentation is explicit that referential integrity checks always bypass row security so that integrity is maintained.
A foreign key pointing at another tenant's row is a valid foreign key.
Nothing in the constraint stops a user in one organisation attaching a line item to another organisation's invoice. Enabling row security on the related tables does not help either, because the constraint check never consults those policies. Something has to check that the parent belongs to the caller's tenant, either in the schema or at the point of the write.
Supabase
Must be enforced for every table and route.
The enforcement point is precise: with check, on insert and on update. It is a general extension point, and it has to carry the tenancy condition alongside whatever the business requires: that the invoice is in draft, or that the caller holds a finance role. By default the two are not separated. The schema can also close a foreign key without a policy at all, with a composite key that carries the tenant column or a trigger that checks the parent. It is visible in the schema, auditable and testable on its own terms. And it sits in the database, so a correct policy holds against anything that reaches the data, including an application bug or a second client — a real security property that no API-layer check matches.
The cost is that this logic must be designed, written and tested for every exposed table and operation. Because tenancy and the business rule share a clause, is our tenancy correct? cannot be answered without reading the workflow conditions threaded through it. update needs both using and with check; with only the first, a user can re-point a record they legitimately own at a tenant they do not. Omissions do not fail loudly.
CustomAPIs
Nothing to write for standard API routes.
The framework already has the path to the tenant, so tenant object access gates the record on a write as well as a read, and a record cannot be moved to another tenant by an update.
Non-standard routes, where business logic sits in hooks that access other tables, still have to consider tenant boundaries.
The cost is that the checks are round trips to the database, parallelised where they can be, but round trips even so.
The caveat is a schema requirement, since every table needs a single unique primary key. That is what lets the framework derive relationship paths mechanically, and it means a foreign key always points at one column, so a composite key carrying the tenant is not available here.
The trade is between enforcement that sits closest to the data and enforcement that cannot be forgotten. Neither location makes a wrong rule right — a mistaken policy is enforced as faithfully as a correct one. What differs is how many rules there are to get right, and whether a missing one announces itself.
Multi-tenancy is more than a tenant ID
A multi-tenant application needs more than a tenant column. Every read, search, create, update and delete must respect the caller's permitted scope — and that stays true when data is reached through related records, custom operations, views, background processes and events.
Row Level Security is genuinely powerful; its policies can express sophisticated access logic in SQL. But applying it across a multi-tenant application is substantial work. For each exposed table you consider the relationship to the tenant, write the policy logic, decide which operations are allowed, and test the result. Supabase's own RLS guidance recommends configuring grants and policies for every exposed table, defining them per operation, and testing what comes out.
The modelling work does not disappear under CustomAPIs. You still connect each resource to its tenant, directly or through a relationship path. What disappears is translating that relationship into policy SQL for every table and every operation, and keeping the two in step for the life of the system.
Row filtering is not access management
RLS answers one important question: which rows may this request touch? A multi-tenant business application also has to manage the rules that produce that answer.
- Which organisations, divisions and locations exist?
- Which users belong to them, and which roles do they hold at each level?
- Which permissions does each role carry?
- Who can grant, revoke and audit access?
- How does this integrate with an access-management model the business already runs?
- How are the same permissions applied consistently across more than one API?
Supabase provides the primitives to build all of it — tables, claims, functions, policies. But the access-management model, its administration experience and its integration with the rest of the business are yours to design, build and maintain.
CustomAPIs has a separate Access Management control plane for tenancy structures, users, roles, permissions and grants. The API resolves the caller's permitted scope from it and applies that scope consistently. Where a customer already runs an internal access-management model, the integration point is designed to be shaped around their organisation structure and permission rules rather than pushed down into table-by-table policy SQL.
This is the row in the table above that reads build a roles and permissions model. It is a substantial body of application work.
CRUD is only part of a business API
Generated table routes are useful, and business applications immediately need more than them: submit, approve, publish, allocate, close, reverse. Records carry a status lifecycle, and access is often conditional on it — a partner sees approved records but not drafts.
Supabase supports this. Depending on the design you reach for database functions and RPC, Edge Functions, triggers, or an application API in front. That is a normal architecture, and in many production systems Supabase settles into being a capable database, auth and realtime layer behind one.
CustomAPIs is intended to be that layer. Standard routes and business-specific operations live in the same framework, under the same tenant scope, permissions, validation and audit behaviour. A state transition, its permission check, its audit record and the event it emits are part of one API operation rather than four things wired together. Supabase offers database webhooks and Realtime for distributing database changes; CustomAPIs emits committed-write application events to the customer's chosen infrastructure.
None of which makes business rules disappear. They stay ordinary application code, because they are genuinely specific to the business. What stops recurring is rebuilding the common API concerns around each new operation.
Understanding where business rules live
Tenancy is not the only rule an API enforces. Validation, workflow conditions and what may follow a write all have to live somewhere.
Both build on Postgres, so database constraints apply under either approach, as does any database logic you deliberately choose to keep there. The difference is not whether database rules are possible. It is where application-specific API behaviour is intended to live: Supabase offers Postgres functions, triggers and Edge Functions; CustomAPIs puts it in the API pipeline.
Business rules in Supabase
Business rules live in Postgres or in Edge Functions.
On a generated CRUD route the extension point is a trigger, with before to modify or reject the row and after for what follows. That is where write-time logic goes, usually in PL/pgSQL. A database function can also be exposed as an RPC route. By default it runs as the caller, so policies on the tables it touches still apply. A security definer function runs with the permissions of whoever owns it, usually postgres, so it can access data outside those policies. In that case, the function itself has to enforce the caller's tenant scope.
An Edge Function can call Supabase either as the current user or with service-role credentials. As the current user, row policies still apply. With service-role credentials, the function has full database access, so tenant scope and permissions have to be enforced in its code.
Those rules are spread across several mechanisms, and hooking the request itself comes down to a single point. db-pre-request names a function to run before a request is served, which Supabase documents for rate limits, additional key checks and quotas. There is nothing comparable further along. Anything that has to happen around a particular write is a trigger, which fires on the write rather than on the request, and knows about rows rather than about the call that produced them.
Business rules in CustomAPIs
CustomAPIs covers an extensive range of business functionality in declarations. Beyond that, the request pipeline has named stages, and most of them take hooks.
- Identity check. Establishes who is calling. Always runs.
- Access check. Resolves what the caller may do and whether they may call this route at all. Always runs.
- Validation. Before and after hooks, with schema patches as its override.
- Tenant object access. Enforces tenancy, foreign keys included. Override, before and after hooks. On CRUD routes these receive the transaction, so writes and rollbacks happen atomically with the rest of the request.
- The operation. Your business rules, with its audit record inside the same transaction. Override, before and after hooks.
- Response shaping. Override, before and after hooks.
- Event publication. Override, before and after hooks, after the write has committed.
The first two are not hook points, so a route cannot skip them by accident, and a public route is an explicit opt-out rather than an omission.
A hook can cross a tenant boundary. The resolved context is handed to it, but nothing forces it to be used, so a hook that consults another table or resource needs the care any hand-written query needs. The surface is small, only those places rather than every route, but it is real.
This is an API-boundary architecture: all application reads and writes to business data go through the API.
Who decides the size and shape of a read
Every tenant's reads run on the same database, so the cost of one read is paid by all of them. The question is who decides how expensive a read is allowed to be, the caller who writes the query or the API that serves it. The case to worry about is not an attack. A customer asking for a large amount of their own data, which they are entitled to see, can slow the service down for every other tenant.
Reads in Supabase
Read shapes are the caller's to choose. Columns and related tables come from the query string, following foreign keys, in what PostgREST calls resource embedding. Row policies apply to each table independently, so embedding is not a way around them. What the caller is choosing is how much work the database does.
But you cannot restrict which shapes a caller asks for, or how large they come back. Access is granted per table, so a caller who may read two related tables may ask for them together in any combination.
Supabase caps a response at 1,000 rows by default, a limit its own documentation recommends keeping low to bound accidental or malicious requests. That cap applies per table level rather than per response. A thousand parent rows can each carry a thousand embedded rows, so a request that looks bounded at a thousand can return a million, and another level of nesting multiplies it again.
The caller sets limit and offset on each embedded resource, so the shape is theirs to choose. When an embedded list is the part that hits the cap, the response is still a 200 and the range header describes only the top-level rows, so nothing in it says rows were dropped. PostgREST has an open issue for exactly this.
A Postgres view takes that choice back, fixing a shape on the server that a caller can filter further but not widen. A view has to be protected for tenancy in its own right, though. In Supabase a view is normally created by the postgres role, which owns the tables, so row policies on them are not applied to reads through it, and unless it is created with security_invoker a view over tenant data can return rows from every tenant. Each view is another object whose tenancy you have to get right.
Reads in CustomAPIs
Reads are declared too, and the declaration is narrower on purpose. A view is one-to-one or many-to-one, with sorting and filtering under the API's control rather than the caller's, and it can carry narrowing the caller cannot relax. Paging is always on, with a fixed ceiling on how many rows a request can return. Total counts are optional. Both exist so that one tenant cannot make the database slow for everyone else. A view has no one-to-many leg, so there is no embedded collection for that ceiling to be multiplied by, and it bounds the whole response rather than one level of it. Because the view is an API-layer declaration rather than a database object, it inherits the same tenant scope and permissions as every other route, so there is no second object whose access rules have to be set correctly on their own.
Managed Postgres is a commodity
Supabase manages the database for you, which is a real convenience. It is also not scarce. Managed Postgres is a mature market. Aurora, RDS, Cloud SQL, Neon and others offer more operational control and a clearer path when you outgrow the tier you are on. CustomAPIs runs against your own Postgres wherever you choose to put it, which keeps the database decision independent of the API decision.
The obvious objection
There is a real question in picking a specialised framework over a large platform, and it deserves a straight answer rather than a paragraph of reassurance. CustomAPIs is a smaller company than Supabase.
What limits the exposure is what you keep. The database is yours, on infrastructure you control, in a schema you own. Business logic is ordinary application code. The framework removes repetition around your model — it does not hold your data or your deployment. What that means in writing is set out in the ownership, licence and continuity terms.
Which approach fits
Start with who will operate it. Supabase can be managed through its web interface to a large degree, which suits a team whose strength is SQL and Postgres. CustomAPIs is a Node package with a declarative surface, which suits a team that works in TypeScript and wants the API declared in code it reviews and versions. Either way the multi-tenancy itself is expert work — a dashboard changes where you write the policy, not what it takes to get it right.
| Supabase fits when… | CustomAPIs fits when… |
|---|---|
| Your team's strength is SQL and Postgres, and a web interface for schema, auth and inspection is an advantage | Your team works in Node and TypeScript, and wants the API surface declared in code it reviews and versions |
| You want one vendor for database, auth, storage, realtime and hosting | You don't want to write tenant isolation, and you want to get started quickly |
| Clients talk directly to the data API and that suits the application | Business operations and CRUD must share one permission model |
| The access model is still moving while you find product shape | There is an in-house access-management model to integrate |
| The team has no appetite for running infrastructure | Audit and committed-write events are part of the API contract |
| CRUD needs little customisation | You want control of the database, hosting and deployment |
The first two rows on the left are real reasons a good team picks Supabase and never regrets it.
The actual decision
The choice is not between generated routes and hand-written ones. In a multi-tenant business system you will need both standard data access and business-specific operations, whichever platform you start from.
Supabase gives you Postgres, a generated data API and a set of genuinely powerful primitives, and asks you to compose tenancy, permission scope and audit out of them. CustomAPIs gives you those three as the foundation and asks you to model your tenancy relationships properly. Both are real offers. Six questions separate them, and none of them is about whether the platform can do it.
- Who will operate it? Supabase is administered largely through a web interface, but multi-tenancy is policy SQL wherever you type it — the dashboard changes the surface, not the expertise. CustomAPIs is a Node package inside your service, so whoever maintains the API is whoever writes TypeScript.
- How long until it works? Three steps per resource, against grants, four policies, a helper function and their tests — for every table you expose.
- How hard will it be to maintain? Access rules derived from the model move when the model moves. Access rules spread across tables, policies and tests have to be kept in step by hand — increasingly by people who did not write them.
- Will it still fit? Organisation models change: a division level added, a new role, a partner tier. Under Supabase that can mean an extensive review of access-management code and configuration.
- Does it leave you an architecture to build on? The second API that needs the same permissions, the integration with an access-management model the business already runs, the committed-write events other systems consume.
- Can it scale far enough? The database ceiling matters, and so does whether you can move when you reach it — which depends a good deal on whose infrastructure it is sitting on.
Capable teams can build all of this on Supabase. That was never the question.
Sources
- Supabase — Data API
- Supabase — Row Level Security
- Supabase — Auth
- Supabase — Edge Functions
- Supabase — Database webhooks
- Supabase — Realtime
- Supabase — Roles, superuser access and unsupported operations
- PostgreSQL — Row Security Policies
Statements about Supabase were checked against its public documentation in September 2026. Supabase is a trademark of Supabase Inc.; we are not affiliated with, endorsed by or a reseller of Supabase. Supabase ships quickly — if something here has gone out of date, tell us and we will correct it.