Supabase
Sign users in with Supabase Auth and read and write tables through PostgREST.
@chelone/supabase gives an admin two providers for a Supabase project: an
auth provider over Supabase Auth and a data provider over the project's
PostgREST API. The package is framework-free, so the same code serves
@chelone/react and @chelone/vue. Only the callback page wiring in
Recovery and invite links differs.
This page covers the package. The login, forgot password and set password pages are a separate part of the Supabase integration, covered in Supabase Pages.
Install
pnpm add @chelone/supabase @supabase/supabase-js@supabase/supabase-js is a peer dependency (^2.48.1), so your app owns the
version. Create the client once and share it with both providers:
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
);Auth provider
supabaseAuthProvider(client, options?) returns an
auth provider backed by client.auth.
import { supabaseAuthProvider } from "@chelone/supabase";
import { supabase } from "./supabase";
<Admin authProvider={supabaseAuthProvider(supabase)} requireAuth>
...
</Admin>;In Vue, pass the same value as the authProvider prop of <Admin>.
| Method | What it does |
|---|---|
login | { email, password } calls signInWithPassword; { provider } starts OAuth. Any other params throw. |
logout | signOut. |
checkAuth | Rejects when getSession returns no session. |
checkError | Rejects on a 401, a 403, or a 400 AuthSessionMissingError, which logs the user out. |
getIdentity | Reads the signed-in user, see Identity. |
getPermissions | Resolves options.getPermissions(user), or undefined without that option. |
setPassword | { access_token, refresh_token, password }: sets the session, then the password. |
resetPassword | { email, redirectTo?, captchaToken? }: sends the reset email. |
handleCallback | Resolves a recovery or invite link, see below. |
Options
getIdentity(user)replaces the identity read from the user's metadata. It may return a promise.getPermissions(user)receives the signed-in user. Its result is whatgetPermissionsresolves.redirectTois where the OAuth provider sends the user back. AredirectTopassed tologinwins. It is also the path prefix of the set-password redirect thathandleCallbackreturns.
supabaseAuthProvider(supabase, {
redirectTo: `${window.location.origin}/auth-callback`,
getPermissions: (user) => user.app_metadata.role,
});OAuth sign-in
Pass the provider name to login:
await authProvider.login({ provider: "github" });SupabaseOAuthProvider lists the names the type accepts: apple, azure,
bitbucket, discord, facebook, github, gitlab, google, keycloak,
linkedin, notion, slack, spotify, twitch, twitter and workos.
Supabase sends the user to the provider and back to redirectTo. Set that URL
to the app's /auth-callback route so handleCallback runs when the user
returns.
Identity
Without a getIdentity option, the identity is built from the user:
idis the user'sid.fullNameisuser_metadata.full_name, falling back to the email.avatarisuser_metadata.avatar_url.
Recovery and invite links
A password recovery or invite email carries the user's tokens in a link. Both need the same setup:
- In the Supabase dashboard, under Authentication, URL Configuration, add the
app's
/auth-callbackroute to the redirect URLs, for examplehttps://admin.example.com/auth-callback. Pass the same URL asredirectTotoresetPassword. - Mount the callback page. Chelone's gate ignores a redirect on a
checkAuthrejection, so an unauthenticated user cannot be redirected to the callback page. The route is public and sits outside the gate.
In React, set the authCallback prop to an element:
import { AuthCallback } from "@/components/admin/auth/auth-callback";
<Admin
authProvider={supabaseAuthProvider(supabase)}
authCallback={<AuthCallback />}
>
...
</Admin>;In Vue, fill the #authCallback slot of <Admin> with your callback page. The
route is /auth-callback, outside the layout and the auth gate.
handleCallback reads access_token, refresh_token and type from the URL,
whether they sit in the query or in the hash.
- For
typeofrecoveryorinvite, it returns a redirect to/set-password(behind theredirectTooption, if set) with the tokens as query params.forgotPasswordPathandsetPasswordPathexport the two paths. - For anything else it returns nothing. An OAuth return is read from the URL by
supabase-jsitself. - A recovery or invite link without an access or refresh token is an error:
handleCallbackrejects with "Missing access_token or refresh_token for a recovery or invite link". The callback page shows it.
readSupabaseUrlParams(location?) is the reader handleCallback uses. It
returns { accessToken, refreshToken, type }, each null when absent.
Messages
The Supabase pages read their text from message keys. The package ships
supabaseEnglishMessages and supabaseFrenchMessages. Merge them into the
catalog you give createI18n, after defaultMessages:
import { createI18n, defaultMessages } from "@chelone/core";
import {
supabaseEnglishMessages,
supabaseFrenchMessages,
} from "@chelone/supabase";
export const i18n = createI18n({
locale: "en",
messages: {
en: { ...defaultMessages, ...supabaseEnglishMessages },
fr: { ...defaultMessages, ...supabaseFrenchMessages },
},
fallbackLocale: "en",
});Keys keep the ra-supabase. prefix (for example ra-supabase.auth.email), so
a catalog written for ra-supabase carries over. %{provider} placeholders are
written {provider}. See Translation for the
catalog itself.
Data provider
supabaseDataProvider(options) is a data provider
over <instanceUrl>/rest/v1.
import { supabaseDataProvider } from "@chelone/supabase";
import { supabase } from "./supabase";
export const dataProvider = supabaseDataProvider({
instanceUrl: import.meta.env.VITE_SUPABASE_URL,
apiKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
client: supabase,
});Options
instanceUrlis the project URL.apiKeyis a legacy anon key or asb_publishable_key.clientsupplies the session. Each call sends the user's access token, so row level security applies. With noclient, or no session, noAuthorizationheader goes out and the key is the only credential.defaultListOpis the operator for a filter key with no@opsuffix. It defaults toeq.primaryKeysmaps a resource to its key columns. A resource not listed is keyed byid.schemais a schema name, or a function returning one, for projects that expose a schema other thanpublic.sortOrdersets where nulls go:AscendingNullsLastDescendingNullsFirst(the default),AscendingNullsFirstDescendingNullsLast,AscendingNullsFirstDescendingNullsFirstorAscendingNullsLastDescendingNullsLast.
Content-Range
getList asks PostgREST for an exact count and reads the total from the
Content-Range response header. A missing header is an error. When the browser
calls a project from another origin, the API must expose it with
Access-Control-Expose-Headers: Content-Range. A total of * becomes an
undefined total.
Meta
Each data provider call takes these keys in meta. <List> and the list
controller do not take meta. Pass it in the params of useGetList, or of
dataProvider.getList:
meta.columnsis a column list, as a string or an array. The default is*.meta.embedis a list of related tables. Each becomesname(*)in the select, soembed: ["comments"]selects*,comments(*).meta.schemaoverrides theschemaoption for this call. Reads send it asAccept-Profile, writes asContent-Profile.meta.nullsfirstandmeta.nullslasttaketruefor every sort column or a list of columns, and win oversortOrder.meta.headersadds request headers and wins over the ones the provider sets.
A key that fails its check throws a TypeError naming the field, so a wrong
meta.headers never sends the call without the header you meant to send. Keys
the provider does not know are ignored.
const { data } = useGetList("posts", {
meta: { columns: ["id", "title"], embed: ["author"] },
});Compound keys
List the key columns in primaryKeys:
supabaseDataProvider({
instanceUrl,
apiKey,
client: supabase,
primaryKeys: { order_items: ["order_id", "line"] },
});Chelone needs one id per record. For a compound key the provider adds an id
that is the JSON array of the key values, such as [12,3], and removes it again
before a write. A sort on id sorts on every key column. An id that does not
decode to that many values is an error.
Filters
A filter key is a column name, optionally followed by @ and an operator:
field@op. Without a suffix the key uses defaultListOp.
const filter = {
"status@in": ["draft", "review"],
"views@gte": 100,
"title@ilike": "launch",
};| Operator | PostgREST meaning |
|---|---|
eq, neq | equal, not equal |
gt, gte, lt, lte | greater, greater or equal, less, less or equal |
like, ilike | pattern match, case-insensitive pattern match |
match, imatch | regular expression match, case-insensitive |
in | one of a list |
is | null, true, false |
isdistinct | not equal, treating null as a value |
fts, plfts, phfts, wfts | full-text search: default, plain, phrase, web |
cs, cd, ov | contains, contained in, overlaps (arrays, ranges, JSON) |
sl, sr, nxr, nxl, adj | range: left of, right of, no extend right, no extend left, adjacent |
not, all, any | the PostgREST modifiers |
likeandilikesplit the value on spaces and add one condition per word, each wrapped in*: a search forlaunch planmatches rows containing both words.- An array value with
inbecomesin.(a,b). Withcs,cdorovit becomes a Postgres array literal,{a,b}. With another operator the items join with commas. nullwitheqbecomesis.null, and withneqbecomesnot.is.null.- A
Datebecomes its ISO string. An object value on a key with an operator is sent as JSON. An object value on a plain key nests the column path:{ author: { name: "Ada" } }filtersauthor.name. - A value of
undefinedis skipped.
or and and
The keys or and and take an object of conditions and produce a PostgREST
logic group. Values inside the group are quoted when they hold a comma,
parenthesis, quote or backslash.
{
or: {
"status@eq": "draft",
"and": { "views@gte": 100, "views@lt": 500 },
},
}This sends or=(status.eq.draft,and(views.gte.100,views.lt.500)).
Errors
A 401 or 403 from PostgREST is an HttpError. Chelone passes it to
checkError on supabaseAuthProvider, which rethrows it and logs the user out.
Pass supabaseAuthProvider to <Admin> alongside the data provider for this to
apply.
getOne, update and delete turn PostgREST's 406 for a missing row into a
404.
Generating resources
The guessers read a resource's Zod schema. The
chelone-supabase bin writes those schemas, and a typed registry of them, from
your project's types. Write the types file with the Supabase CLI, add supazod
as a dev dependency, and run the bin:
supabase gen types typescript --local > supabase/types.ts
pnpm add -D supazod
pnpm exec chelone-supabase gen --input supabase/types.ts --out-dir src/resourcesThe bin also needs typescript installed, which most apps already have. It
writes two files to --out-dir:
schemas.tsholds the Zod schemas, written bysupazod.resources.tsis adefineResourcesmap of them, with theRegisteraugmentation that types the resource names.
// Written by `chelone-supabase gen`. Do not edit this file; run the command again instead.
import { defineResources } from "@chelone/core";
import {
publicAuthorsRowSchema,
publicPostsRowSchema,
} from "./schemas";
export const resources = defineResources({
authors: { schema: publicAuthorsRowSchema },
posts: {
schema: publicPostsRowSchema,
references: { author_id: "authors" },
},
});
declare module "@chelone/core" {
interface Register {
resources: typeof resources;
}
}Pass resources to <Admin> in React, and as registry in Vue:
import { resources } from "./resources/resources";
<Admin dataProvider={dataProvider} resources={resources}>
...
</Admin>;<Admin :data-provider="dataProvider" :registry="resources">
...
</Admin>What the bin reads:
- The schema of each resource is the
Rowschema of its table or view, so it holds every column the row has. - A foreign key becomes an entry in
references, which makes the guessers renderReferenceFieldandReferenceInputfor that column. - A view gets list and show only. The scaffold below gives a view no edit page.
What it does not read:
- Compound foreign keys.
- Foreign keys to a table in another schema. Pass
--schemato read a schema other thanpublic. - Primary keys other than
id. For those, setprimaryKeysin the options of the data provider, see Compound keys.
The two files are overwritten each time, so run the command again after a migration and do not edit them. Put your own resource config in a separate file.
Add --scaffold react or --scaffold vue to print a <Resource> element for
each table and view, with a guesser on each page. Paste them into <Admin>:
pnpm exec chelone-supabase gen --input supabase/types.ts --out-dir src/resources --scaffold reactRealtime
supabaseRealtimeTransport(client, options?) carries realtime
events over Supabase Realtime broadcast channels, one channel per topic. Wrap
the data provider with realtimeDataProvider and pass the same client:
import { addEventsForMutations, realtimeDataProvider } from "@chelone/core";
import {
supabaseDataProvider,
supabaseRealtimeTransport,
} from "@chelone/supabase";
import { supabase } from "./supabase";
const base = supabaseDataProvider({
instanceUrl: import.meta.env.VITE_SUPABASE_URL,
apiKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
client: supabase,
});
export const transport = supabaseRealtimeTransport(supabase);
export const dataProvider = addEventsForMutations(
realtimeDataProvider(base, transport),
transport,
);- A channel is named
chelone:followed by the topic, such aschelone:resource/posts.channelPrefixchanges the prefix. - A channel opens on the first subscribe to its topic and is removed with the last. A publish to a topic nobody here listens to opens a channel for the send and removes it.
getStatusis"idle"with no subscription,"connected"once every channel has joined,"reconnecting"while one has an error, and"disconnected"afterclose()or when a channel closes.supabase-jsretries on its own, so there is noreconnectoption. A channel that rejoins calls theonReconnectlisteners.- A publish rejects when Supabase does not acknowledge it, and goes to
onErrorassend_failed. close()removes every channel and sets"disconnected". A publish after it rejects.- A publish reaches this client's own subscribers, as it does on the other clients.
Private channels
Channels are private by default. Supabase checks a private channel against the
policies on realtime.messages, so the transport does nothing until you add
one. This policy lets any signed-in user listen to and send on the channels:
create policy "chelone realtime read"
on realtime.messages
for select
to authenticated
using (realtime.messages.extension = 'broadcast');
create policy "chelone realtime write"
on realtime.messages
for insert
to authenticated
with check (realtime.messages.extension = 'broadcast');Narrow the using and with check expressions to limit who reaches which
topic. The realtime.topic() function returns the channel name, so
realtime.topic() like 'chelone:resource/posts%' limits a policy to one
resource.
Pass private: false to use public channels:
supabaseRealtimeTransport(supabase, { private: false });A public channel needs no policy, and anyone who has the project's URL and
publishable key can read its events and send to it, signed in or not. Events
carry record data, such as the data of an updated event, so use a public
channel only for topics that hold nothing private.
Record locks
supabaseLockProvider(options) keeps record locks
in a table every user shares, so a lock taken in one browser stops another
user. Pass it as locks to realtimeDataProvider, and the transport as its
publisher so every client hears of each lock and unlock:
import { realtimeDataProvider } from "@chelone/core";
import {
supabaseDataProvider,
supabaseLockProvider,
supabaseRealtimeTransport,
} from "@chelone/supabase";
import { supabase } from "./supabase";
const instanceUrl = import.meta.env.VITE_SUPABASE_URL;
const apiKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
const base = supabaseDataProvider({ instanceUrl, apiKey, client: supabase });
export const transport = supabaseRealtimeTransport(supabase);
const locks = supabaseLockProvider({
instanceUrl,
apiKey,
client: supabase,
publisher: transport,
});
export const dataProvider = realtimeDataProvider(base, transport, { locks });instanceUrl,apiKeyandclientare as forsupabaseDataProvider. The user's session applies, so row level security decides.schemais a schema name for a table outsidepublic. Unlike the data provider's option, it takes a string, not a function.tableis the table of locks. It defaults tochelone_locks.- The lock identity is the
identitythatLockOnMountpasses. Use the signed-in user's id, whichsupabaseAuthProviderreturns as the identity'sid. The policies below compare it withauth.uid(). - Taking a lock held by another user throws
LockConflictErrorwith the existing lock. Taking your own lock again returns it.
Create the table and its policies in the SQL editor or a migration:
create table chelone_locks (
resource text not null,
record_id text not null,
identity text not null,
created_at timestamptz not null default now(),
primary key (resource, record_id)
);
alter table chelone_locks enable row level security;
create policy "chelone locks are readable"
on chelone_locks
for select
to authenticated
using (true);
create policy "chelone locks are taken by their holder"
on chelone_locks
for insert
to authenticated
with check (identity = auth.uid()::text);
create policy "chelone locks are released by their holder"
on chelone_locks
for delete
to authenticated
using (identity = auth.uid()::text);The primary key on (resource, record_id) is what keeps a record to one lock:
a second insert fails with a 409 and the provider reads the holder. There is no
update policy, so a holder cannot hand a lock to another user.
A lock never expires. A tab that closes without releasing its lock, or a lost
connection, leaves the row in place and the record stays locked. Delete the old
rows on a schedule. With the pg_cron extension on, this job runs every five
minutes and removes locks older than one hour:
select cron.schedule(
'chelone-locks-expire',
'*/5 * * * *',
$$delete from chelone_locks where created_at < now() - interval '1 hour'$$
);Choose an age longer than the longest edit a user can reasonably make, since a lock removed under an open edit lets another user take it.
What is not supported
- Cursor pagination. A
getListwith a cursor throws "supabaseDataProvider does not support cursor pagination". Use page andperPage. rpc/resources. Only tables and views are read and written.