Supabase est un ensemble de services construits autour d'un PostgreSQL ordinaire : une base de données, l'authentification des utilisateurs, le stockage de fichiers, des fonctions à la demande et des abonnements aux changements en temps réel. Le code de la plateforme est open source sous licence Apache 2.0, la bibliothèque cliente sous licence MIT, et le dépôt affiche plus de cent sept mille étoiles.
La principale différence avec la concurrence
Tout le reste découle d'une seule décision : un vrai PostgreSQL se trouve dessous, plutôt qu'un moteur sur mesure avec son propre langage de requête.
Son importance se voit dès qu'un projet cesse d'être simple. Tu disposes des jointures, des vues, des déclencheurs, des fonctions dans la base, des index partiels et de tout l'écosystème d'extensions, y compris la recherche plein texte et le travail avec des vecteurs. Les solutions construites sur leur propre modèle de données commencent à ce moment-là à avoir besoin de contournements, alors qu'ici tu écris simplement une requête.
La deuxième conséquence concerne la sortie. Puisqu'il s'agit d'un PostgreSQL ordinaire, tu exportes les données avec un outil standard et tu les charges ailleurs. Ce n'est pas un avantage marketing mais un avantage réel, car changer de fournisseur revient à déplacer la base de données et à remplacer une chaîne de connexion, plutôt qu'à réécrire la couche d'accès aux données.
La troisième chose, c'est le fonctionnement de l'API. L'interface qui expose les tables en HTTP est générée automatiquement à partir du schéma de la base, donc ajouter une colonne apparaît immédiatement dans l'API sans écrire une ligne de code. Pratique pendant le prototypage et risqué plus tard, ce qu'on verra dans un instant.
Quand c'est un bon choix
Ça vaut le coup de nommer ces scénarios clairement, car cette plateforme est souvent choisie par réflexe, et tous les projets n'en tirent pas profit.
Elle fonctionne le mieux pour un produit construit par une petite équipe, là où arriver vite à une application qui marche compte vraiment. Authentification, base de données, fichiers et fonctions au même endroit signifient que la première semaine est consacrée au produit plutôt qu'à assembler une infrastructure pièce par pièce. C'est une vraie économie, et la principale raison du succès de cette plateforme.
Le deuxième bon scénario, c'est un projet dont les données sont relationnelles et où une requête avec plusieurs jointures, ou un rapport, apparaîtra inévitablement. Choisir une base de données documentaire dans ce cas signifie que cette fonctionnalité arrivera plus tard, et moins bien.
Elle s'en sort moins bien dans deux situations. La première, c'est une application avec une logique métier serveur conséquente, où tu as de toute façon besoin de ta propre couche de services. La partie de la plateforme consacrée à l'accès depuis le navigateur devient alors inutile, et ce qui reste, c'est du PostgreSQL hébergé, que tu peux obtenir moins cher ailleurs, chez Neon par exemple.
La seconde, c'est l'obligation de garder les données sur ta propre infrastructure. Une variante auto-hébergée existe et est open source, mais elle comprend une bonne douzaine de services, donc la maintenir est un vrai travail, pas juste démarrer un conteneur. Chiffre ça honnêtement avant de décider. Sur un petit projet, PocketBase offre la même chose sous la forme d'un seul exécutable qui embarque une base SQLite, l'authentification, les fichiers et une API. Le prix de cette simplicité, c'est un seul processus sans réplication et un statut antérieur à la version 1.0, où la compatibilité ascendante n'est pas garantie et où une mise à jour demande parfois des étapes manuelles.
La sécurité au niveau des lignes, la partie critique
C'est la section la plus importante de ce texte, car elle couvre le seul endroit où une erreur coûte une fuite de données.
Puisque le client se connecte à la base de données directement depuis un navigateur, la seule barrière entre un utilisateur et les données de quelqu'un d'autre, ce sont les règles d'accès définies dans la base elle-même. Il n'y a pas de couche serveur applicatif qui vérifierait les permissions au passage. Une table sans règles activées est visible par n'importe qui connaissant l'adresse du projet et la clé publique, et cette clé arrive dans le navigateur par définition.
Le symptôme est traître, car il n'y en a pas. L'application fonctionne correctement, les tests passent, et les données sont accessibles à tout le monde. La découverte se fait généralement quand un inconnu ouvre l'onglet réseau de ses outils de développement.
La règle pratique est donc la suivante : active les règles d'accès sur chaque table immédiatement après sa création, avant d'écrire la moindre ligne de code client. Une table verrouillée par défaut se traduit par une liste vide, ce que tu remarques tout de suite, contrairement à une table ouverte.
Le deuxième piège concerne la clé de service. À côté de la clé publique, il en existe une seconde qui contourne toutes les règles d'accès. Elle appartient exclusivement au code serveur, et son apparition dans une variable accessible depuis le navigateur équivaut à livrer toute la base de données. Dans Next.js, la frontière, c'est le préfixe du nom de la variable d'environnement, et ça vaut le coup de le vérifier deux fois.
La troisième chose, c'est le coût des règles. Une règle est une condition ajoutée à chaque requête, donc une règle qui s'appuie sur une sous-requête vers une autre table s'exécute pour chaque ligne. À mille enregistrements, personne ne le remarque ; à un million, la requête ne répond plus. Si une liste a soudainement ralenti après l'ajout de règles, c'est le premier endroit à vérifier.
Qu'est-ce que Supabase et pourquoi est-il si populaire ?
Supabase est un Backend-as-a-Service (BaaS) open source, souvent appelé « le Firebase open source ». Mais contrairement à Firebase, qui utilise du NoSQL (Firestore), Supabase est construit sur PostgreSQL, la base de données relationnelle open source la plus puissante.
Supabase offre tout ce qu'il faut pour construire une application moderne :
- Base de données PostgreSQL avec un query builder puissant
- Authentification avec connexions sociales et magic links
- Storage pour les fichiers et les images
- Abonnements en temps réel (Real-time)
- Edge Functions (Deno)
- Vector embeddings pour l'IA
Pourquoi choisir Supabase ?
Open source
Tout le code de Supabase est disponible sur GitHub. Tu peux l'auto-héberger, le modifier, et avoir la certitude de ne pas être enfermé chez un seul fournisseur.
PostgreSQL sous le capot
Tu as toute la puissance de PostgreSQL :
- Transactions ACID
- Clés étrangères et contraintes
- Recherche plein texte
- Support JSON/JSONB
- Extensions (PostGIS, pg_vector, etc.)
- Row Level Security
Expérience développeur
Un excellent SDK, une documentation d'API générée automatiquement, un dashboard pour gérer les données, et une intégration facile avec les frameworks populaires.
Installation et configuration
Création d'un projet
- Crée un compte sur supabase.com
- Crée un nouveau projet
- Enregistre l'URL et les clés API
Installation du SDK
# JavaScript/TypeScript
npm install @supabase/supabase-js
# Hooks spécifiques à React
npm install @supabase/auth-helpers-react @supabase/auth-helpers-nextjsConfiguration du client
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
// Types générés par la CLI Supabase
import { Database } from '@/types/database.types'
export const supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// Client côté serveur (Next.js App Router)
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
export const createServerClient = () => {
return createServerComponentClient<Database>({ cookies })
}Variables d'environnement
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci... # Uniquement côté serveur !Base de données PostgreSQL
Création des tables
Dans le Dashboard Supabase ou en SQL :
-- Table des utilisateurs (étend auth.users)
CREATE TABLE public.profiles (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
full_name TEXT,
avatar_url TEXT,
bio TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Table des articles
CREATE TABLE public.posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
author_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
content TEXT,
slug TEXT UNIQUE NOT NULL,
published BOOLEAN DEFAULT FALSE,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index pour les performances
CREATE INDEX posts_author_id_idx ON public.posts(author_id);
CREATE INDEX posts_published_idx ON public.posts(published) WHERE published = true;
CREATE INDEX posts_slug_idx ON public.posts(slug);Opérations CRUD
// SELECT - récupération des données
const { data: posts, error } = await supabase
.from('posts')
.select(`
id,
title,
slug,
content,
published_at,
author:profiles(id, username, avatar_url)
`)
.eq('published', true)
.order('published_at', { ascending: false })
.limit(10)
// SELECT avec filtrage
const { data } = await supabase
.from('posts')
.select('*')
.or('title.ilike.%react%,content.ilike.%react%')
.gte('published_at', '2024-01-01')
.lte('published_at', '2024-12-31')
// INSERT
const { data: newPost, error } = await supabase
.from('posts')
.insert({
author_id: userId,
title: 'Mon premier article',
slug: 'mon-premier-article',
content: 'Bonjour le monde !',
})
.select()
.single()
// UPDATE
const { data, error } = await supabase
.from('posts')
.update({
title: 'Titre mis à jour',
updated_at: new Date().toISOString(),
})
.eq('id', postId)
.select()
.single()
// DELETE
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)
// UPSERT (création ou mise à jour)
const { data, error } = await supabase
.from('profiles')
.upsert({
id: userId,
username: 'john_doe',
full_name: 'John Doe',
})
.select()
.single()Requêtes avancées
// Pagination
const pageSize = 10
const page = 1
const { data, count } = await supabase
.from('posts')
.select('*', { count: 'exact' })
.range((page - 1) * pageSize, page * pageSize - 1)
// Recherche plein texte
const { data } = await supabase
.from('posts')
.select('*')
.textSearch('title', 'react hooks', {
type: 'websearch',
config: 'english'
})
// Agrégations via RPC
// Crée d'abord une fonction en SQL :
// CREATE FUNCTION get_post_stats() RETURNS TABLE (total bigint, published bigint)
// AS $$ SELECT COUNT(*), COUNT(*) FILTER (WHERE published) FROM posts $$
// LANGUAGE sql;
const { data } = await supabase.rpc('get_post_stats')Authentification
Email et mot de passe
// Inscription
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'securepassword123',
options: {
data: {
full_name: 'John Doe',
},
emailRedirectTo: 'https://myapp.com/auth/callback',
},
})
// Connexion
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'securepassword123',
})
// Déconnexion
await supabase.auth.signOut()
// Réinitialisation du mot de passe
await supabase.auth.resetPasswordForEmail('user@example.com', {
redirectTo: 'https://myapp.com/reset-password',
})OAuth (connexions sociales)
// GitHub
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: 'https://myapp.com/auth/callback',
scopes: 'read:user user:email',
},
})
// Google
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://myapp.com/auth/callback',
queryParams: {
access_type: 'offline',
prompt: 'consent',
},
},
})
// Fournisseurs pris en charge :
// google, github, gitlab, bitbucket, azure, discord,
// facebook, twitter, apple, spotify, slack, twitch, notionMagic Link (sans mot de passe)
const { error } = await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
emailRedirectTo: 'https://myapp.com/auth/callback',
},
})Session utilisateur
// Récupère la session actuelle
const { data: { session } } = await supabase.auth.getSession()
// Récupère l'utilisateur
const { data: { user } } = await supabase.auth.getUser()
// Écoute les changements d'authentification
supabase.auth.onAuthStateChange((event, session) => {
console.log('Événement auth :', event)
console.log('Session :', session)
if (event === 'SIGNED_IN') {
// Utilisateur connecté
} else if (event === 'SIGNED_OUT') {
// Utilisateur déconnecté
} else if (event === 'TOKEN_REFRESHED') {
// Le token a été rafraîchi
}
})Intégration avec Next.js App Router
// app/auth/callback/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
if (code) {
const supabase = createRouteHandlerClient({ cookies })
await supabase.auth.exchangeCodeForSession(code)
}
return NextResponse.redirect(new URL('/', request.url))
}// middleware.ts
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const supabase = createMiddlewareClient({ req, res })
const { data: { session } } = await supabase.auth.getSession()
// Protège les routes qui nécessitent une authentification
if (!session && req.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', req.url))
}
return res
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}Row Level Security (RLS)
RLS est une fonctionnalité de sécurité clé de Supabase. Elle permet de définir des politiques d'accès au niveau de la ligne.
-- Active RLS sur la table
ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
-- Politique : tout le monde peut lire les articles publiés
CREATE POLICY "Public posts are viewable by everyone"
ON public.posts FOR SELECT
USING (published = true);
-- Politique : les utilisateurs peuvent lire leurs propres articles
CREATE POLICY "Users can view own posts"
ON public.posts FOR SELECT
USING (auth.uid() = author_id);
-- Politique : les utilisateurs peuvent créer des articles
CREATE POLICY "Users can create posts"
ON public.posts FOR INSERT
WITH CHECK (auth.uid() = author_id);
-- Politique : les utilisateurs peuvent modifier leurs propres articles
CREATE POLICY "Users can update own posts"
ON public.posts FOR UPDATE
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);
-- Politique : les utilisateurs peuvent supprimer leurs propres articles
CREATE POLICY "Users can delete own posts"
ON public.posts FOR DELETE
USING (auth.uid() = author_id);
-- Politique avec des rôles (par ex. admin)
CREATE POLICY "Admins can do everything"
ON public.posts FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
)
);Storage
Envoi de fichiers
// Envoi depuis le navigateur
const file = event.target.files[0]
const fileExt = file.name.split('.').pop()
const fileName = `${userId}/${Date.now()}.${fileExt}`
const { data, error } = await supabase.storage
.from('avatars')
.upload(fileName, file, {
cacheControl: '3600',
upsert: true,
})
// Récupère l'URL publique
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName)Téléchargement et suppression
// Téléchargement
const { data, error } = await supabase.storage
.from('documents')
.download('folder/file.pdf')
// Liste des fichiers
const { data: files } = await supabase.storage
.from('documents')
.list('folder', {
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' },
})
// Suppression
const { error } = await supabase.storage
.from('avatars')
.remove(['avatar1.png', 'avatar2.png'])Politiques de stockage
-- Politique : les utilisateurs peuvent envoyer des fichiers dans leur propre dossier
CREATE POLICY "Users can upload own avatar"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Politique : accès en lecture public
CREATE POLICY "Avatars are publicly accessible"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');Abonnements en temps réel
// Écoute les changements de la table
const channel = supabase
.channel('posts-changes')
.on(
'postgres_changes',
{
event: '*', // INSERT, UPDATE, DELETE ou *
schema: 'public',
table: 'posts',
filter: 'published=eq.true', // Filtre optionnel
},
(payload) => {
console.log('Changement reçu !', payload)
if (payload.eventType === 'INSERT') {
// Nouvel article
setPosts(prev => [payload.new, ...prev])
} else if (payload.eventType === 'UPDATE') {
// Article mis à jour
setPosts(prev =>
prev.map(p => p.id === payload.new.id ? payload.new : p)
)
} else if (payload.eventType === 'DELETE') {
// Article supprimé
setPosts(prev => prev.filter(p => p.id !== payload.old.id))
}
}
)
.subscribe()
// Nettoyage
return () => {
supabase.removeChannel(channel)
}Broadcast (événements personnalisés)
// Envoi
const channel = supabase.channel('room:123')
channel.send({
type: 'broadcast',
event: 'cursor-position',
payload: { x: 100, y: 200 },
})
// Réception
channel.on('broadcast', { event: 'cursor-position' }, (payload) => {
console.log('Position du curseur :', payload.payload)
})Présence (statut en ligne)
const channel = supabase.channel('room:123')
// Suit la présence
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('Utilisateurs en ligne :', Object.keys(state).length)
})
channel.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log('Utilisateur a rejoint :', key)
})
channel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log('Utilisateur a quitté :', key)
})
// Suit l'utilisateur actuel
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: userId,
online_at: new Date().toISOString(),
})
}
})Edge Functions
Les Edge Functions sont des fonctions serverless écrites en TypeScript et exécutées sur Deno :
// supabase/functions/send-email/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
serve(async (req) => {
const { to, subject, body } = await req.json()
// Envoie l'email avec, par exemple, Resend
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'noreply@myapp.com',
to,
subject,
html: body,
}),
})
const data = await response.json()
return new Response(
JSON.stringify(data),
{ headers: { 'Content-Type': 'application/json' } }
)
})Appel depuis le client :
const { data, error } = await supabase.functions.invoke('send-email', {
body: {
to: 'user@example.com',
subject: 'Bienvenue !',
body: '<h1>Bienvenue dans notre application</h1>',
},
})Pour un message unique, cet appel suffit. Dès que des séquences de bienvenue et des rappels de fin d'essai apparaissent, il est plus simple d'envoyer l'événement lui-même depuis la fonction vers un outil comme Loops, qui garde le contenu et les conditions d'envoi de son côté. Il facture cependant au contact plutôt qu'au message, donc une liste d'adresses mortes coûte le même prix qu'une liste active.
Génération des types TypeScript
# Installe la CLI Supabase
npm install -g supabase
# Connexion
supabase login
# Génère les types
supabase gen types typescript --project-id your-project-id > types/database.types.tsUtilisation des types générés :
import { Database } from '@/types/database.types'
type Post = Database['public']['Tables']['posts']['Row']
type NewPost = Database['public']['Tables']['posts']['Insert']
type UpdatePost = Database['public']['Tables']['posts']['Update']
// Tu as maintenant un typage complet
const { data } = await supabase
.from('posts')
.select('*')
.single()
// data est de type Post | nullComparaison Supabase vs Firebase
| Caractéristique | Supabase | Firebase |
|---|---|---|
| Base de données | PostgreSQL (relationnelle) | Firestore (NoSQL) |
| Open Source | Oui | Non |
| Auto-hébergement | Oui | Non |
| SQL | Support complet | Aucun |
| Relations | Clés étrangères, JOINs | Aucune |
| Temps réel | Changements PostgreSQL | Snapshots Firestore |
| Auth | Capacités similaires | Capacités similaires |
| Storage | Capacités similaires | Capacités similaires |
| Edge Functions | Deno | Node.js |
| Tarification | Plus transparente | Pay-as-you-go |
Tarifs (2025)
Free
- 500 MB de base de données
- 1 GB de stockage
- 2 GB de bande passante
- 50 000 MAU (auth)
- 500 000 invocations d'Edge Functions
Pro ($25/mois)
- 8 GB de base de données
- 100 GB de stockage
- 250 GB de bande passante
- 100 000 MAU
- 2M invocations d'Edge Functions
- Sauvegardes quotidiennes
Team ($599/mois)
- Tout ce qui est inclus dans Pro
- Conformité SOC2
- SSO/SAML
- Support prioritaire
- PITR sur 14 jours
Enterprise (sur mesure)
- Infrastructure dédiée
- Contrats personnalisés
- Garanties de SLA
Bonnes pratiques
1. Utilise toujours RLS
-- Ne laisse jamais de tables sans RLS en production !
ALTER TABLE public.your_table ENABLE ROW LEVEL SECURITY;2. Utilise les types TypeScript
# Régénère les types après chaque changement de schéma
supabase gen types typescript --project-id xxx > types/database.types.ts3. Optimise les requêtes
// Sélectionne uniquement les colonnes dont tu as besoin
const { data } = await supabase
.from('posts')
.select('id, title, slug') // Pas select('*')
.limit(10)4. Utilise des index
-- Ajoute des index pour les colonnes souvent filtrées
CREATE INDEX posts_published_idx ON posts(published_at)
WHERE published = true;FAQ
Supabase est-il gratuit ?
Oui, le plan Free permet de construire et de tester des applications. Pour la production, le plan Pro est recommandé.
Puis-je auto-héberger Supabase ?
Oui, tous les composants sont open source. La documentation inclut des instructions pour Docker et Kubernetes.
Comment migrer depuis Firebase ?
Supabase propose des outils de migration. Le principal changement, c'est le passage d'un modèle de données NoSQL à un modèle SQL.
Supabase est-il scalable ?
Oui, PostgreSQL est reconnu pour sa bonne scalabilité. Supabase propose aussi des read replicas et le connection pooling.
Tarifs et le piège du plan gratuit
| Plan | Prix mensuel | Base de données | Fichiers | Sortie (egress) | Utilisateurs actifs |
|---|---|---|---|---|---|
| Free | 0 USD | 500 MB | 1 GB | 5 GB | 50k |
| Pro | 25 USD | 8 GB par projet | 100 GB | 250 GB | 100k |
| Team | 599 USD | 8 GB par projet | 100 GB | 250 GB | 100k |
| Enterprise | devis personnalisé | négocié | négocié | négocié | négocié |
Le plan payant inclut dix dollars de crédit de calcul, qui couvrent la plus petite instance, tout ce qui est plus gros étant facturé en plus. Le nombre de requêtes à l'API est illimité sur tous les plans.
Le crédit s'applique cependant par organisation, alors que le calcul se facture par projet, et c'est la surprise la plus courante sur cette plateforme. Une organisation avec un seul projet sur la plus petite instance paie vingt-cinq dollars, puisque le crédit couvre la totalité. Un deuxième projet sur la même instance fait monter la facture à trente-cinq dollars, bien que le plan n'ait pas changé. Avec plusieurs environnements, où un projet séparé sert de copie de préproduction, compte cette ligne une fois par environnement plutôt qu'une seule fois au total.
La chose la plus importante à propos du plan gratuit ne concerne cependant pas les limites, mais la mise en pause. Les projets gratuits sont mis en pause après une semaine d'inactivité, et la limite de projets actifs est de deux. Pour l'apprentissage et les expérimentations, ça n'a guère d'importance, puisqu'un projet reprend manuellement. Pour tout ce qui doit être accessible à d'autres personnes, c'est disqualifiant : une application montrée à un client après une coupure de deux semaines ne répondra pas.
Quand tu estimes les coûts, surveille la sortie (egress), car c'est la ligne la plus souvent sous-estimée. Servir des images directement depuis le stockage de fichiers consomme cette allocation plus vite que tout le reste, en particulier quand les fichiers ne passent pas par un réseau de diffusion de contenu. Si ton application distribue des photos, chiffre ça séparément avant de choisir un plan.
Erreurs courantes
La première, c'est une table sans règles d'accès, déjà évoquée plus haut. C'est la seule erreur de cette liste qui se termine par une fuite de données plutôt que par une panne.
La deuxième, c'est de garder des connexions sans pooling. PostgreSQL impose une limite stricte sur les connexions simultanées, et un environnement d'exécution qui invoque des fonctions à la demande en ouvre une nouvelle à chaque appel. Supabase expose une adresse groupée séparée, et c'est celle que le code sans état doit utiliser. Les migrations de schéma et les tâches d'administration longues ont cependant besoin d'une connexion directe, car le mode transactionnel du pool ne prend pas tout en charge.
La troisième, c'est de compter sur l'API générée automatiquement pour une logique complexe. Une interface qui expose des tables fonctionne très bien pour lire une liste et écrire un enregistrement, et cesse de suffire dès qu'une opération doit modifier trois tables de façon atomique. C'est le rôle des fonctions en base invoquées comme des procédures, pas d'une chaîne de requêtes séparées depuis un navigateur, car cette chaîne ne porte aucune garantie de cohérence.
La quatrième concerne les abonnements aux changements en temps réel. C'est tentant de les brancher partout, mais chaque abonnement est une connexion ouverte et un flux d'événements à gérer côté client. Pour une liste mise à jour une fois par heure, un simple fetch est moins cher et plus simple ; garde le mécanisme temps réel pour ce qui change réellement sous les yeux d'un utilisateur.
La cinquième, c'est d'oublier les migrations. Cliquer dans le dashboard est rapide et pratique, et six mois plus tard, personne ne sait pourquoi une colonne a cette forme ni comment recréer la base de données depuis zéro. Garder les changements de schéma dans des fichiers de migration versionnés dans le dépôt, c'est la même discipline que garder le code sous contrôle de version.
La sixième, visible seulement au premier trafic sérieux, c'est l'absence d'index pour les requêtes que tu exécutes réellement. La base de données suppose un index sur la clé primaire et sur les clés étrangères, mais pas sur la colonne qui sert à filtrer une liste, ni sur la combinaison de colonnes utilisée pour le tri. À quelques centaines de lignes, la différence est invisible, puisque la base lit tout de toute façon ; à quelques centaines de milliers, la même page cesse de s'ouvrir. Regarde de temps en temps les requêtes les plus lentes dans le dashboard et vérifie si l'une d'elles parcourt une table entière.
Le code source se trouve dans le dépôt Supabase, et les plans actuels sur la page des tarifs.