IA Engineering

Supabase : base de données, API et logique SQL

Tables, types, relations, index, profils utilisateurs, API REST générée par Supabase, fonctions, triggers et procédures SQL : comment structurer et exposer une base Postgres avec RLS et grants dès la création, selon la documentation officielle.

Base de données PostgreSQL Supabase tables colonnes gestion

L’essentiel en 30 secondes

  • Les tables de public sont exposées par l'API : RLS et grants dès la création ; un droit manquant donne une erreur, une policy restrictive un résultat vide.
  • Préférez timestamptz, text, numeric et bigint (ou uuid) ; Postgres n'indexe pas les clés étrangères, créez les index vous-même ; default now() ne met pas à jour updated_at.
  • Clé publishable côté client, clé secret uniquement côté serveur ; les fonctions s'appellent avec supabase.rpc() (security invoker par défaut), les procédures non.
  • Rattachez les données utilisateur à auth.users par sa clé primaire via public.profiles, créée par un trigger dont l'échec bloque l'inscription.

Supabase donne à chaque projet une base PostgreSQL et génère automatiquement, à partir de son schéma, une API qui permet à votre site ou à votre application de lire et d'écrire les données sans backend intermédiaire. Pourquoi cela compte : la structure de vos tables, leurs droits d'accès et leur logique SQL décident à la fois de la qualité de vos données et de ce qu'un visiteur peut voir ou modifier. Trois règles propres à Supabase : les tables de public sont exposées par l'API, donc RLS et grants dès la création ; les données utilisateur se rattachent à auth.users par sa clé primaire ; et tout changement de schéma passe par une migration12. Ce guide suit le chemin d'une donnée : la table, ses relations, l'API qui l'expose, puis la logique SQL (fonctions, triggers) qui l'entoure.

Comment créer une première table ?

create table public.articles (
  id bigint generated always as identity primary key,
  title text not null,
  slug text not null unique,
  status text not null default 'draft'
    check (status in ('draft', 'published', 'archived')),
  author_id uuid not null references auth.users (id) on delete cascade,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
create index articles_author_id_idx on public.articles (author_id);

alter table public.articles enable row level security;
revoke all on table public.articles from anon, authenticated;
grant select on table public.articles to anon;
grant select, insert, update, delete on table public.articles to authenticated;

create policy "Published articles are public" on public.articles
for select to anon, authenticated
using ( status = 'published' );

create policy "Authors manage their articles" on public.articles
for all to authenticated
using ( (select auth.uid()) = author_id )
with check ( (select auth.uid()) = author_id );

Une table créée depuis le Table Editor a la RLS activée automatiquement ; en SQL, c'est à vous de le faire1. Les grants explicites sont nécessaires parce que, sur les projets existants, une nouvelle table de public reçoit par défaut les privilèges SELECT, INSERT, UPDATE et DELETE pour anon, authenticated et service_role3. Pour empêcher cela sur les futures tables, la documentation donne ces deux commandes3 :

alter default privileges for role postgres in schema public
  revoke select, insert, update, delete on tables from anon, authenticated, service_role;

alter default privileges for role postgres in schema public
  revoke execute on functions from anon, authenticated, service_role;

Les nouvelles tables et fonctions restent alors injoignables tant que vous ne les ouvrez pas explicitement. Supabase fait évoluer ce comportement vers une exposition opt-in3. Dans les deux cas, deux couches se complètent : les grants décident si un rôle atteint la table, la RLS décide quelles lignes il voit3.

Attention : default now() sur updated_at ne remplit la colonne qu'à l'insertion. Pour la mettre à jour à chaque modification, il faut un trigger (voir plus bas, la section sur les triggers).

Quels types choisir ?

Les recommandations de la documentation Supabase1 :

BesoinType conseilléPourquoi
Date et heure d'un événementtimestamptzEnregistre un instant ; timestamp seulement pour une heure « murale » (ex. ouverture à 9 h partout)
TextetextMême stockage que varchar(n), sans limite à migrer ; une contrainte check si la longueur doit être bornée
Montants, décimales exactesnumericreal/double precision ne représentent pas 0,10 exactement ; money dépend de la configuration du serveur
Identifiant numériquebigint identityinteger plafonne à 2 147 483 647
Identifiant non devinableuuidgen_random_uuid() est natif dans Postgres
Données semi-structuréesjsonbIndexable, interrogeable en SQL

UUID ou bigint ? Les deux sont courants comme clé primaire1. L'UUID évite d'exposer un compteur dans les URL et se génère côté client ; l'identity est plus compact.

Comment modéliser les relations ?

Un-à-plusieurs

create table public.projects (
  id bigint generated always as identity primary key,
  owner_id uuid not null references auth.users (id) on delete cascade,
  name text not null
);
create index projects_owner_id_idx on public.projects (owner_id);

Postgres ne crée pas d'index sur la colonne qui porte la clé étrangère11. Sans lui, les jointures, les policies RLS et les suppressions en cascade balaient toute la table ; le Performance Advisor signale ces clés non indexées12.

Plusieurs-à-plusieurs

create table public.article_tags (
  article_id bigint not null references public.articles (id) on delete cascade,
  tag_id bigint not null references public.tags (id) on delete cascade,
  primary key (article_id, tag_id)
);
-- la clé primaire indexe article_id, pas tag_id
create index article_tags_tag_id_idx on public.article_tags (tag_id);

Une clé primaire composite n'indexe que sa première colonne pour les filtres : ajoutez un index sur la seconde2. L'exemple suppose que la table tags existe, créée comme articles.

Cascade ou pas ?

on delete cascade est adapté aux données qui n'ont pas de sens sans leur parent (le profil d'un compte supprimé). Pour des factures ou des commandes, on delete restrict ou set null évite qu'une suppression de compte efface un historique que vous devez conserver. C'est une décision métier, pas un réglage par défaut.

Comment relier ses tables aux utilisateurs ?

Le schéma auth n'est pas exposé par l'API : on crée une table public.profiles qui référence auth.users par sa clé primaire, seule colonne garantie stable par Supabase6.

create table public.profiles (
  id uuid not null references auth.users on delete cascade,
  username text unique,
  bio text,
  primary key (id)
);
alter table public.profiles enable row level security;

La ligne de profil se crée automatiquement à l'inscription avec un trigger sur auth.users (voir la section sur les triggers plus bas).

Comment organiser les schémas ?

  • public : ce que l'API REST expose. N'y mettez que ce que les clients doivent atteindre.
  • Un schéma non exposé (par exemple private) pour les tables internes et les fonctions security definer2.
  • Un schéma dédié (par exemple api) peut définir clairement ce qui est exposé : les objets d'un schéma exposé doivent porter des grants et des policies RLS, et la surface de l'API est plus facile à auditer3.

Comment interroger la base avec l'API REST ?

Chaque table, vue ou fonction exposée devient une route sous https://<project_ref>.supabase.co/rest/v1/, utilisable directement depuis le navigateur ou en complément de votre propre backend7. Cette API est générée par PostgREST. Ce qui la rend sûre n'est pas l'API elle-même, ce sont les deux couches vues plus haut : grants et RLS3.

Quelles routes l'API génère-t-elle ?

GET    /rest/v1/posts                  lire
POST   /rest/v1/posts                  créer
GET    /rest/v1/posts?id=eq.<uuid>     lire une ligne
PATCH  /rest/v1/posts?id=eq.<uuid>     modifier
DELETE /rest/v1/posts?id=eq.<uuid>     supprimer
POST   /rest/v1/rpc/<fonction>         appeler une fonction Postgres

L'API suit les changements de schéma immédiatement, gère les vues, les vues matérialisées et les relations entre tables, et traduit chaque requête en une seule instruction SQL7.

Qui peut appeler l'API, et avec quelle clé ?

  • Clé publishable (sb_publishable_…) dans le navigateur ou l'application mobile : elle correspond au rôle anon, ou authenticated si l'utilisateur est connecté8.
  • Clé secret (sb_secret_…) uniquement côté serveur : elle correspond au rôle service_role, qui contourne toute RLS. Elle est refusée si elle est envoyée depuis un navigateur8.
  • Les anciennes clés anon et service_role (des JWT commençant par eyJ) sont dépréciées d'ici fin 20268.

Un droit manquant renvoie une erreur de permission ; une policy qui ne correspond à aucune ligne renvoie un résultat vide. Vérifiez le droit avant de déboguer la policy. Pour masquer une table de l'API, ne lui accordez aucun droit pour anon et authenticated, ou placez-la dans un schéma non exposé. Détails sur les policies dans le guide Row Level Security.

Comment lire des données avec supabase-js ?

// Colonnes choisies
const { data, error } = await supabase.from('posts').select('id, title, created_at')

// Relations (clés étrangères) imbriquées
const { data: posts } = await supabase.from('posts').select(`
  id,
  title,
  profiles ( username, avatar_url ),
  categories ( name, slug )
`)

// Avec le nombre total de lignes
const { data: page, count } = await supabase
  .from('posts')
  .select('id, title', { count: 'exact' })
  .range(0, 9)

Les jointures fonctionnent quand une clé étrangère relie les tables ; PostgREST les découvre à partir du schéma7.

Quels filtres, tris et paginations ?

.eq('status', 'published')          // égal
.neq('status', 'draft')              // différent
.gt('views', 100)  .gte('views', 100) // supérieur (ou égal)
.lt('views', 100)  .lte('views', 100) // inférieur (ou égal)
.in('status', ['published', 'draft'])
.ilike('title', '%supabase%')        // contient, sans tenir compte de la casse
.is('deleted_at', null)
.or('status.eq.published,status.eq.draft')

.order('created_at', { ascending: false })
.range(0, 9)       // lignes 1 à 10 (bornes incluses)
.limit(20)
.single()          // erreur si 0 ou plusieurs lignes
.maybeSingle()     // null si aucune ligne

Comment créer, modifier et supprimer ?

// Insérer et récupérer la ligne créée
const { data, error } = await supabase
  .from('posts')
  .insert({ title: 'Mon article', content: '…', user_id: userId })
  .select()

// Créer ou mettre à jour selon la clé primaire
await supabase.from('profiles').upsert({ id: userId, username: 'jeandupont' }).select()

// Modifier
await supabase.from('posts').update({ title: 'Nouveau titre' }).eq('id', postId).select()

// Supprimer
await supabase.from('posts').delete().eq('id', postId)

Mettez toujours un filtre sur update et delete. Côté RLS, une modification ou une suppression n'agit que sur les lignes que la policy autorise.

Comment appeler l'API sans SDK, avec curl ?

La clé va dans l'en-tête apikey, le JWT de l'utilisateur connecté dans Authorization. Les nouvelles clés ne sont pas des JWT : ne les envoyez pas en Authorization: Bearer8.

# Lire les articles publiés
curl 'https://<project_ref>.supabase.co/rest/v1/posts?status=eq.published&select=id,title' \
  -H "apikey: sb_publishable_..." \
  -H "Authorization: Bearer <JWT_DE_L_UTILISATEUR>"

# Créer un article et recevoir la ligne créée
curl -X POST 'https://<project_ref>.supabase.co/rest/v1/posts' \
  -H "apikey: sb_publishable_..." \
  -H "Authorization: Bearer <JWT_DE_L_UTILISATEUR>" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"title": "Mon article", "content": "..."}'

Sans utilisateur connecté, omettez l'en-tête Authorization : la requête s'exécute avec le rôle anon.

Comment interpréter les erreurs ?

CodeStatut HTTPSignification
PGRST116406Zéro ou plusieurs lignes alors qu'une seule était attendue (.single())9
42501401 si anonyme, 403 si connectéPrivilèges insuffisants : droit manquant, ou écriture refusée par une policy9
23505409Violation d'unicité (doublon)9
if (error) {
  switch (error.code) {
    case 'PGRST116': return null              // pas (ou trop) de résultat pour .single()
    case '42501':    throw new Error('Accès refusé')
    case '23505':    throw new Error('Doublon')
    default:         throw error
  }
}

Une lecture filtrée par la RLS ne déclenche pas d'erreur 42501 : elle renvoie simplement moins de lignes, voire aucune.

Comment écrire de la logique SQL : fonctions, triggers, procédures ?

Dans Supabase, la logique côté base repose sur trois objets PostgreSQL. Les fonctions (create function) s'appellent depuis le client avec supabase.rpc()4. Les triggers exécutent une fonction returns trigger quand une ligne est insérée, modifiée ou supprimée5. Les procédures (create procedure) s'exécutent avec CALL et ne sont pas exposées par l'API REST, qui ne sait appeler que des fonctions10.

Côté sécurité, une fonction s'exécute par défaut avec les droits de l'appelant (security invoker), ce que recommande Supabase. Si vous passez en security definer, il faut fixer search_path = '', et ne pas placer la fonction dans un schéma exposé par l'API4.

Comment écrire une fonction appelable depuis le client ?

create or replace function public.get_user_age(p_user_id uuid)
returns integer
language sql
stable
security invoker
set search_path = ''
as $$
  select extract(year from age(p.birthdate))::integer
  from public.profiles p
  where p.id = p_user_id;
$$;
// côté client (JavaScript)
const { data, error } = await supabase.rpc('get_user_age', { p_user_id: userId })

En security invoker, la fonction respecte la RLS de profiles : elle ne renvoie rien sur un profil que l'utilisateur n'a pas le droit de lire. L'API accepte toujours l'appel en POST ; elle l'accepte aussi en GET quand la fonction ne modifie pas la base, ce qui est le cas d'une fonction stable ou immutable10. Une fonction n'est exposée que si elle se trouve dans un schéma exposé et que le rôle de l'appelant peut l'exécuter10 : les fonctions d'un schéma non exposé, comme private, restent utilisables dans les policies et les triggers mais pas depuis le client, ce qui est souvent le but. Exemple d'appel avec des arguments : supabase.rpc('get_trending_posts', { limit_count: 10, since_date: '2026-01-01' }).

Par défaut, toute fonction reçoit le droit EXECUTE3. Pour en réserver une aux utilisateurs connectés, révoquez le droit pour public et pour anon4 :

revoke execute on function public.get_user_age(uuid) from public;
revoke execute on function public.get_user_age(uuid) from anon;

Comment mettre à jour updated_at automatiquement ?

create or replace function public.set_updated_at()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
  new.updated_at = now();
  return new;
end;
$$;

create trigger posts_set_updated_at
before update on public.posts
for each row execute function public.set_updated_at();

Le trigger before update modifie la ligne avant son écriture ; il doit renvoyer new. Dans create trigger, les mots-clés execute function et execute procedure sont équivalents, mais le second est historique et déprécié : la cible est toujours une fonction5.

Comment créer un profil à chaque inscription ?

C'est le trigger le plus courant dans Supabase. Version de la documentation6 :

create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, first_name, last_name)
  values (
    new.id,
    new.raw_user_meta_data ->> 'first_name',
    new.raw_user_meta_data ->> 'last_name'
  );
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

Cet exemple suppose que profiles comporte les colonnes first_name et last_name. Trois points à connaître. La fonction est en security definer parce que c'est le service Auth qui insère dans auth.users et qu'il n'a pas de droits sur public.profiles ; avec search_path = '', chaque table doit être préfixée par son schéma4. Si le trigger échoue, l'inscription échoue : testez-le avec des métadonnées manquantes6. Enfin, les métadonnées (raw_user_meta_data) sont fournies par l'utilisateur à l'inscription : ne copiez jamais de cette façon une valeur qui accorde un droit12.

Pour versionner ce trigger : avec le moteur de diff pg-delta, supabase db pull capture un trigger sur auth.users qui appelle une fonction de public ; un trigger dont la fonction vit dans auth reste exclu et doit être livré par une migration écrite à la main (voir notre guide de la CLI et des migrations).

Quand utiliser une procédure plutôt qu'une fonction ?

create procedure public.archive_old_posts(p_before timestamptz)
language sql
as $$
  update public.posts set status = 'archived' where created_at < p_before;
$$;

call public.archive_old_posts(now() - interval '1 year');

Une procédure ne renvoie pas de valeur comme une fonction et s'appelle avec CALL. Comme supabase.rpc() ne peut pas l'appeler10, on la réserve aux traitements lancés en SQL : migrations, tâches planifiées, scripts d'administration. Pour une logique appelée par l'application, écrivez une fonction.

SQL ou PL/pgSQL ?

  • language sql : une ou plusieurs requêtes, sans variables ni conditions. Suffisant pour la plupart des lectures.
  • language plpgsql : variables, if, boucles, exceptions. Obligatoire pour une fonction de trigger qui manipule new et old5.
  • Déclarez la volatilité (immutable, stable, volatile) : elle conditionne l'appel en GET par l'API et les optimisations du planificateur10.

Quelles précautions avant de multiplier les triggers ?

  • Un trigger qui en déclenche un autre rend le comportement difficile à suivre : documentez la chaîne ou regroupez la logique.
  • Toute fonction security definer : search_path = '', noms qualifiés, et placement hors des schémas exposés, sinon elle devient appelable par l'API avec les droits de son créateur4. Les Advisors de Supabase signalent les fonctions security definer exécutables par anon ou authenticated12.
  • Pour une tâche longue ou un appel HTTP externe, préférez une Edge Function serverless à un trigger : un trigger ralentit la transaction qui l'a déclenché.

Comment générer les types TypeScript ?

supabase gen types typescript --local > types.gen.ts

La commande lit le schéma de la base locale ; les types générés se relancent après chaque migration. Toute la chaîne (génération, contrôle en CI, déploiement) est décrite dans notre guide de la CLI Supabase. Pour laisser un assistant IA lire vos tables, voir connecter Claude à Supabase avec MCP.

Pour la vue d'ensemble de la plateforme, voyez notre guide complet de Supabase. Pour concevoir un modèle de données avec nous, voyez notre conception de bases Supabase sur mesure.

Sources

  1. Tables and data (documentation Supabase)
  2. Row Level Security (documentation Supabase)
  3. Securing your API : default privileges (documentation Supabase)
  4. Database Functions (documentation Supabase)
  5. CREATE TRIGGER (documentation PostgreSQL 18)
  6. User Management : profiles et trigger sur auth.users (documentation Supabase)
  7. Data REST API (documentation Supabase)
  8. Understanding API keys (documentation Supabase)
  9. Errors (documentation PostgREST)
  10. Functions as RPC (documentation PostgREST)
  11. Constraints : foreign keys (documentation PostgreSQL 18)
  12. Advisors (documentation Supabase)

Écrit par

CTO & Chief Digital Strategist chez AdSim, Liège

Georges est CTO et Chief Digital Strategist d’AdSim.

  • Campaign Manager Brand Controls Basics
  • Bid Manager Brand Controls Basics
  • AdWords Video Brand Controls Basics

Questions fréquentes

Vos questions sur la base de données Supabase

Faut-il écrire un backend si l'on utilise l'API REST de Supabase ?

Pas pour le CRUD : le navigateur peut interroger l'API directement, à condition que droits et RLS soient correctement définis. Un backend (ou une Edge Function) reste utile pour la logique qui exige une clé secrète ou un appel à un service tiers.

Où trouver l'URL de l'API et les clés ?

L'URL est dans Integrations > Data API du tableau de bord, les clés dans Settings > API Keys. Le bouton Connect affiche aussi l'URL et la clé publishable prêtes à copier.

Peut-on masquer certaines tables de l'API ?

Oui : ne leur accordez aucun droit pour anon et authenticated, ou placez-les dans un schéma non exposé. Supabase conseille un schéma dédié (par exemple api) pour délimiter clairement ce qui est public.

Peut-on appeler une fonction d'un autre schéma que public avec rpc() ?

Seulement si ce schéma est exposé dans les réglages de l'API. Les fonctions placées dans un schéma non exposé, comme private, restent utilisables dans les policies et les triggers mais pas depuis le client, ce qui est souvent le but.

Pourquoi ma fonction renvoie-t-elle un résultat vide depuis l'application et des lignes dans le SQL Editor ?

Le SQL Editor s'exécute avec un rôle qui n'est pas soumis à la RLS, alors que l'application passe par anon ou authenticated. Avec security invoker, la fonction applique les policies de l'appelant : vérifiez les policies et les grants de la table.

Commentaires

Chaque commentaire est relu avant publication, en général sous 24 h ouvrées. Les liens promotionnels ne sont pas publiés.

Aucun commentaire pour l’instant. Une question sur l’article ? Posez-la ci-dessous.

Laisser un commentaire

Jamais publié. Sert à vous prévenir d’une réponse.

Votre commentaire sera publié après relecture. Un lien au plus, pas de message promotionnel.

Point de départ

On applique cette méthode à votre compte ?

L’audit gratuit part de vos données, pas d’un exemple. Vous recevez le diagnostic sous 48 h ouvrées.

« Chez AdSim, c’est un vrai expert du digital qui lit votre demande et vous répond sous 48 h ouvrées. »

Valérie Matrige, CEO & co-fondatrice

Réponse sous 48 h ouvrées · Diagnostic 100 % gratuit · Sans engagement · Zéro revente de vos données