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:

src/supabase.ts
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>.

MethodWhat it does
login{ email, password } calls signInWithPassword; { provider } starts OAuth. Any other params throw.
logoutsignOut.
checkAuthRejects when getSession returns no session.
checkErrorRejects on a 401, a 403, or a 400 AuthSessionMissingError, which logs the user out.
getIdentityReads the signed-in user, see Identity.
getPermissionsResolves 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.
handleCallbackResolves 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 what getPermissions resolves.
  • redirectTo is where the OAuth provider sends the user back. A redirectTo passed to login wins. It is also the path prefix of the set-password redirect that handleCallback returns.
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:

  • id is the user's id.
  • fullName is user_metadata.full_name, falling back to the email.
  • avatar is user_metadata.avatar_url.

A password recovery or invite email carries the user's tokens in a link. Both need the same setup:

  1. In the Supabase dashboard, under Authentication, URL Configuration, add the app's /auth-callback route to the redirect URLs, for example https://admin.example.com/auth-callback. Pass the same URL as redirectTo to resetPassword.
  2. Mount the callback page. Chelone's gate ignores a redirect on a checkAuth rejection, 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 type of recovery or invite, it returns a redirect to /set-password (behind the redirectTo option, if set) with the tokens as query params. forgotPasswordPath and setPasswordPath export the two paths.
  • For anything else it returns nothing. An OAuth return is read from the URL by supabase-js itself.
  • A recovery or invite link without an access or refresh token is an error: handleCallback rejects 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:

src/i18n.ts
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.

src/data-provider.ts
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

  • instanceUrl is the project URL.
  • apiKey is a legacy anon key or a sb_publishable_ key.
  • client supplies the session. Each call sends the user's access token, so row level security applies. With no client, or no session, no Authorization header goes out and the key is the only credential.
  • defaultListOp is the operator for a filter key with no @op suffix. It defaults to eq.
  • primaryKeys maps a resource to its key columns. A resource not listed is keyed by id.
  • schema is a schema name, or a function returning one, for projects that expose a schema other than public.
  • sortOrder sets where nulls go: AscendingNullsLastDescendingNullsFirst (the default), AscendingNullsFirstDescendingNullsLast, AscendingNullsFirstDescendingNullsFirst or AscendingNullsLastDescendingNullsLast.

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.columns is a column list, as a string or an array. The default is *.
  • meta.embed is a list of related tables. Each becomes name(*) in the select, so embed: ["comments"] selects *,comments(*).
  • meta.schema overrides the schema option for this call. Reads send it as Accept-Profile, writes as Content-Profile.
  • meta.nullsfirst and meta.nullslast take true for every sort column or a list of columns, and win over sortOrder.
  • meta.headers adds 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",
};
OperatorPostgREST meaning
eq, neqequal, not equal
gt, gte, lt, ltegreater, greater or equal, less, less or equal
like, ilikepattern match, case-insensitive pattern match
match, imatchregular expression match, case-insensitive
inone of a list
isnull, true, false
isdistinctnot equal, treating null as a value
fts, plfts, phfts, wftsfull-text search: default, plain, phrase, web
cs, cd, ovcontains, contained in, overlaps (arrays, ranges, JSON)
sl, sr, nxr, nxl, adjrange: left of, right of, no extend right, no extend left, adjacent
not, all, anythe PostgREST modifiers
  • like and ilike split the value on spaces and add one condition per word, each wrapped in *: a search for launch plan matches rows containing both words.
  • An array value with in becomes in.(a,b). With cs, cd or ov it becomes a Postgres array literal, {a,b}. With another operator the items join with commas.
  • null with eq becomes is.null, and with neq becomes not.is.null.
  • A Date becomes 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" } } filters author.name.
  • A value of undefined is 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/resources

The bin also needs typescript installed, which most apps already have. It writes two files to --out-dir:

  • schemas.ts holds the Zod schemas, written by supazod.
  • resources.ts is a defineResources map of them, with the Register augmentation that types the resource names.
src/resources/resources.ts
// 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 Row schema 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 render ReferenceField and ReferenceInput for 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 --schema to read a schema other than public.
  • Primary keys other than id. For those, set primaryKeys in 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 react

Realtime

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:

src/data-provider.ts
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 as chelone:resource/posts. channelPrefix changes 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.
  • getStatus is "idle" with no subscription, "connected" once every channel has joined, "reconnecting" while one has an error, and "disconnected" after close() or when a channel closes. supabase-js retries on its own, so there is no reconnect option. A channel that rejoins calls the onReconnect listeners.
  • A publish rejects when Supabase does not acknowledge it, and goes to onError as send_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:

src/data-provider.ts
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, apiKey and client are as for supabaseDataProvider. The user's session applies, so row level security decides.
  • schema is a schema name for a table outside public. Unlike the data provider's option, it takes a string, not a function.
  • table is the table of locks. It defaults to chelone_locks.
  • The lock identity is the identity that LockOnMount passes. Use the signed-in user's id, which supabaseAuthProvider returns as the identity's id. The policies below compare it with auth.uid().
  • Taking a lock held by another user throws LockConflictError with 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 getList with a cursor throws "supabaseDataProvider does not support cursor pagination". Use page and perPage.
  • rpc/ resources. Only tables and views are read and written.