Construire une application temps réel avec Supabase et Next.js 15 : guide complet

Supabase est devenu l'alternative open-source de référence à Firebase, offrant un backend complet avec PostgreSQL, authentification, abonnements temps réel et stockage de fichiers — le tout prêt à l'emploi. Combiné avec Next.js 15 et l'App Router, vous obtenez un framework full-stack puissant qui gère le rendu côté serveur, les Server Actions et les interactions client de manière fluide.
Dans ce guide, vous allez construire un tableau de tâches collaboratif en temps réel — un mini-Trello où plusieurs utilisateurs peuvent ajouter, modifier et supprimer des tâches qui se synchronisent instantanément sur tous les navigateurs connectés. Vous apprendrez à maîtriser Supabase Auth, Row Level Security (RLS), les triggers de base de données et le système Realtime Broadcast.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- Un compte Supabase — gratuit sur supabase.com
- Des connaissances de base en React et TypeScript
- Une familiarité avec le Next.js App Router (pages, layouts, Server Components)
- Un éditeur de code (VS Code recommandé)
Ce que vous allez construire
Un tableau de tâches collaboratif avec les fonctionnalités suivantes :
- Authentification par email/mot de passe avec routes protégées
- Mises à jour des tâches en temps réel sur tous les clients connectés
- Row Level Security garantissant que chaque utilisateur ne voit que ses propres données
- Server Components pour le chargement initial des données
- Server Actions pour les mutations
- Middleware pour la gestion des sessions
Étape 1 : Créer un projet Supabase
Rendez-vous sur supabase.com/dashboard et créez un nouveau projet. Une fois votre projet prêt, notez ces valeurs depuis Settings > API :
Project URL— l'URL de votre instance Supabaseanon public— clé utilisable côté clientservice_role— accès administrateur (gardez-la secrète)
Étape 2 : Configurer le schéma de base de données
Allez dans le SQL Editor de votre tableau de bord Supabase et exécutez le code suivant :
-- Créer une table profiles liée à auth.users
create table public.profiles (
id uuid references auth.users on delete cascade not null primary key,
email text,
display_name text,
created_at timestamptz default now() not null
);
-- Créer la table des tâches
create table public.tasks (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users on delete cascade not null,
title text not null,
description text,
status text default 'todo' check (status in ('todo', 'in_progress', 'done')),
created_at timestamptz default now() not null,
updated_at timestamptz default now() not null
);
-- Activer Row Level Security
alter table public.profiles enable row level security;
alter table public.tasks enable row level security;
-- Profiles : les utilisateurs peuvent lire et modifier leur propre profil
create policy "Users can view own profile"
on public.profiles for select
using (auth.uid() = id);
create policy "Users can update own profile"
on public.profiles for update
using (auth.uid() = id);
-- Tasks : les utilisateurs peuvent gérer leurs propres tâches
create policy "Users can view own tasks"
on public.tasks for select
using (auth.uid() = user_id);
create policy "Users can create tasks"
on public.tasks for insert
with check (auth.uid() = user_id);
create policy "Users can update own tasks"
on public.tasks for update
using (auth.uid() = user_id);
create policy "Users can delete own tasks"
on public.tasks for delete
using (auth.uid() = user_id);
-- Créer automatiquement un profil à l'inscription
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, email, display_name)
values (new.id, new.email, split_part(new.email, '@', 1));
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- Mettre à jour updated_at automatiquement
create or replace function public.update_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create trigger tasks_updated_at
before update on public.tasks
for each row execute function public.update_updated_at();Ce schéma met en place :
- Une table
profilesqui se remplit automatiquement à l'inscription - Une table
tasksavec suivi de statut - Des politiques RLS pour que chaque utilisateur n'accède qu'à ses propres données
- Des triggers pour les horodatages automatiques
Étape 3 : Configurer le Broadcast temps réel
Pour des mises à jour temps réel scalables, Supabase recommande le Broadcast via triggers de base de données plutôt que l'ancienne approche postgres_changes. Exécutez ceci dans le SQL Editor :
-- Trigger de broadcast pour la table tasks
create or replace function broadcast_task_changes()
returns trigger as $$
begin
perform realtime.broadcast_changes(
'tasks:updates',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
return null;
end;
$$ language plpgsql security definer;
create trigger tasks_broadcast_trigger
after insert or update or delete on public.tasks
for each row execute function broadcast_task_changes();Étape 4 : Initialiser le projet Next.js
npx create-next-app@latest task-board --typescript --tailwind --eslint --app --src-dir
cd task-boardInstallez les packages Supabase :
npm install @supabase/supabase-js @supabase/ssrCréez votre fichier .env.local :
NEXT_PUBLIC_SUPABASE_URL=https://votre-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=votre-cle-anon-iciÉtape 5 : Créer les clients Supabase
Vous avez besoin de deux clients : un pour le côté serveur (Server Components, Server Actions, Middleware) et un pour le navigateur.
Client navigateur
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}Client serveur
// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Appelé depuis un Server Component — on peut ignorer
// si le middleware gère le rafraîchissement des sessions
}
},
},
}
)
}Étape 6 : Ajouter le Middleware pour la gestion des sessions
Le middleware rafraîchit la session utilisateur à chaque requête, empêchant l'expiration des tokens :
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
// Rediriger les utilisateurs non authentifiés vers la connexion
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/signup') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// Rediriger les utilisateurs authentifiés hors des pages d'auth
if (user && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) {
const url = request.nextUrl.clone()
url.pathname = '/dashboard'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}Étape 7 : Construire les pages d'authentification
Page de connexion
// src/app/login/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<h2 className="text-3xl font-bold text-center text-gray-900">
Connexion au Task Board
</h2>
<form onSubmit={handleLogin} className="space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Mot de passe
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
Pas de compte ?{' '}
<a href="/signup" className="text-blue-600 hover:underline">
Créer un compte
</a>
</p>
</div>
</div>
)
}Page d'inscription
// src/app/signup/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function SignupPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const { error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<h2 className="text-3xl font-bold text-center text-gray-900">
Créer votre compte
</h2>
<form onSubmit={handleSignup} className="space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Mot de passe (min. 6 caractères)
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Création...' : 'Créer un compte'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
Déjà un compte ?{' '}
<a href="/login" className="text-blue-600 hover:underline">
Se connecter
</a>
</p>
</div>
</div>
)
}Étape 8 : Construire le Dashboard avec les Server Components
Le dashboard charge les tâches côté serveur pour un affichage initial rapide, puis s'abonne aux changements en temps réel côté client.
Layout du Dashboard
// src/app/dashboard/layout.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-xl font-bold text-gray-900">Task Board</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user.email}</span>
<form action="/auth/signout" method="post">
<button
type="submit"
className="text-sm text-red-600 hover:underline"
>
Déconnexion
</button>
</form>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">{children}</main>
</div>
)
}Server Component : Charger les tâches initiales
// src/app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { TaskBoard } from './task-board'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: tasks, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return <div className="text-red-600">Erreur de chargement : {error.message}</div>
}
return <TaskBoard initialTasks={tasks ?? []} />
}Étape 9 : Créer le composant Task Board temps réel
C'est le cœur de l'application — un composant client qui affiche les colonnes Kanban et s'abonne aux changements en temps réel :
// src/app/dashboard/task-board.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
type Task = {
id: string
title: string
description: string | null
status: 'todo' | 'in_progress' | 'done'
created_at: string
updated_at: string
user_id: string
}
const STATUS_COLUMNS = [
{ key: 'todo', label: 'À faire', color: 'bg-yellow-100 border-yellow-300' },
{ key: 'in_progress', label: 'En cours', color: 'bg-blue-100 border-blue-300' },
{ key: 'done', label: 'Terminé', color: 'bg-green-100 border-green-300' },
] as const
export function TaskBoard({ initialTasks }: { initialTasks: Task[] }) {
const [tasks, setTasks] = useState<Task[]>(initialTasks)
const [newTitle, setNewTitle] = useState('')
const supabase = createClient()
// S'abonner aux changements en temps réel
useEffect(() => {
const channel = supabase
.channel('tasks:updates', { config: { private: true } })
.on('broadcast', { event: 'INSERT' }, (payload) => {
const newTask = payload.payload.record as Task
setTasks((prev) => [newTask, ...prev])
})
.on('broadcast', { event: 'UPDATE' }, (payload) => {
const updated = payload.payload.record as Task
setTasks((prev) =>
prev.map((t) => (t.id === updated.id ? updated : t))
)
})
.on('broadcast', { event: 'DELETE' }, (payload) => {
const deleted = payload.payload.old_record as Task
setTasks((prev) => prev.filter((t) => t.id !== deleted.id))
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
const addTask = async (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { error } = await supabase.from('tasks').insert({
title: newTitle.trim(),
user_id: user.id,
status: 'todo',
})
if (!error) setNewTitle('')
}
const updateStatus = async (taskId: string, status: Task['status']) => {
await supabase.from('tasks').update({ status }).eq('id', taskId)
}
const deleteTask = async (taskId: string) => {
await supabase.from('tasks').delete().eq('id', taskId)
}
return (
<div className="space-y-6">
{/* Formulaire d'ajout */}
<form onSubmit={addTask} className="flex gap-3">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Ajouter une nouvelle tâche..."
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Ajouter
</button>
</form>
{/* Colonnes Kanban */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{STATUS_COLUMNS.map((col) => (
<div key={col.key} className={`rounded-xl border-2 p-4 ${col.color}`}>
<h3 className="font-semibold text-lg mb-4">
{col.label}
<span className="ml-2 text-sm font-normal text-gray-500">
({tasks.filter((t) => t.status === col.key).length})
</span>
</h3>
<div className="space-y-3">
{tasks
.filter((t) => t.status === col.key)
.map((task) => (
<div
key={task.id}
className="bg-white rounded-lg p-4 shadow-sm border"
>
<h4 className="font-medium text-gray-900">{task.title}</h4>
{task.description && (
<p className="text-sm text-gray-500 mt-1">
{task.description}
</p>
)}
<div className="mt-3 flex gap-2 flex-wrap">
{col.key !== 'todo' && (
<button
onClick={() =>
updateStatus(
task.id,
col.key === 'done' ? 'in_progress' : 'todo'
)
}
className="text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200"
>
← Reculer
</button>
)}
{col.key !== 'done' && (
<button
onClick={() =>
updateStatus(
task.id,
col.key === 'todo' ? 'in_progress' : 'done'
)
}
className="text-xs px-2 py-1 bg-blue-100 rounded hover:bg-blue-200 text-blue-700"
>
Avancer →
</button>
)}
<button
onClick={() => deleteTask(task.id)}
className="text-xs px-2 py-1 bg-red-100 rounded hover:bg-red-200 text-red-700"
>
Supprimer
</button>
</div>
</div>
))}
{tasks.filter((t) => t.status === col.key).length === 0 && (
<p className="text-sm text-gray-400 text-center py-4">
Aucune tâche
</p>
)}
</div>
</div>
))}
</div>
</div>
)
}Étape 10 : Ajouter la route de déconnexion
// src/app/auth/signout/route.ts
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function POST() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}Étape 11 : Gestionnaire de callback d'authentification
Supabase redirige les utilisateurs vers une URL de callback après la confirmation par email. Ajoutez cette route :
// src/app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}/dashboard`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth`)
}Tester votre implémentation
- Lancez le serveur de développement :
npm run dev- Inscrivez-vous sur
http://localhost:3000/signupavec un email de test - Créez des tâches et déplacez-les entre les colonnes
- Ouvrez un second onglet — les changements apparaissent en temps réel
- Vérifiez le RLS — connectez-vous avec un autre compte pour vérifier l'isolation des tâches
Dépannage
Problèmes courants et solutions
"relation 'tasks' does not exist" — Assurez-vous d'avoir exécuté le SQL de l'étape 2 sur votre projet Supabase.
Les cookies ne sont pas définis — Vérifiez que votre middleware est configuré avec le bon pattern matcher et que @supabase/ssr est installé.
Les événements temps réel n'arrivent pas — Vérifiez que le trigger broadcast a été créé à l'étape 3. Consultez votre tableau de bord Supabase sous Realtime > Inspector pour voir si les événements circulent.
Erreurs "Unauthorized" — Row Level Security est activé. Assurez-vous que l'utilisateur est authentifié avant de faire des requêtes. Vérifiez que les politiques RLS font correspondre auth.uid() à la bonne colonne.
Session perdue au rafraîchissement — Le middleware devrait gérer le rafraîchissement automatiquement. Si les sessions sont perdues, vérifiez que setAll dans le client serveur écrit correctement les cookies.
Structure du projet
src/
├── app/
│ ├── auth/
│ │ ├── callback/route.ts # Callback OAuth/email
│ │ └── signout/route.ts # Gestionnaire de déconnexion
│ ├── dashboard/
│ │ ├── layout.tsx # Protection auth + en-tête
│ │ ├── page.tsx # Server Component (chargement)
│ │ └── task-board.tsx # Client Component (temps réel)
│ ├── login/page.tsx # Formulaire de connexion
│ └── signup/page.tsx # Formulaire d'inscription
├── lib/
│ └── supabase/
│ ├── client.ts # Client navigateur
│ └── server.ts # Client serveur
└── middleware.ts # Rafraîchissement session + gardes
Prochaines étapes
Maintenant que vous avez un tableau de tâches temps réel fonctionnel, voici comment l'étendre :
- Ajouter des fournisseurs OAuth — Activez la connexion Google, GitHub ou Discord via les paramètres Supabase Auth
- Implémenter le drag-and-drop — Utilisez
@dnd-kit/corepour des interactions Kanban fluides - Ajouter Supabase Storage — Permettez aux utilisateurs de joindre des fichiers aux tâches
- Déployer sur Vercel — Configurez vos variables d'environnement et déployez avec
vercel deploy - Ajouter la Presence — Montrez quels utilisateurs sont actuellement en ligne avec Supabase Realtime Presence
Conclusion
Vous avez construit un tableau de tâches collaboratif full-stack en temps réel avec Supabase et Next.js 15. Les patterns clés que vous avez appris sont :
- Double configuration client — des clients Supabase séparés pour le navigateur et le serveur avec l'App Router
- Gestion des sessions par middleware — rafraîchissement automatique des tokens à chaque requête
- Row Level Security — contrôle d'accès au niveau de la base de données sans écrire de code backend
- Triggers de broadcast — l'approche scalable pour les notifications de changements en temps réel
- Server Components + Client Components — chargement de données côté serveur avec interactivité côté client
Ces patterns s'appliquent à n'importe quel projet Supabase + Next.js, que vous construisiez une application de chat, un dashboard ou un produit SaaS. Supabase gère l'infrastructure lourde pour que vous puissiez vous concentrer sur la logique de votre application.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire une Application Full-Stack avec Drizzle ORM et Next.js 15 : Base de Donnees Type-Safe du Zero a la Production
Apprenez a construire une application full-stack type-safe avec Drizzle ORM et Next.js 15. Ce tutoriel pratique couvre la conception de schemas, les migrations, les Server Actions, les operations CRUD et le deploiement avec PostgreSQL.

Authentifier votre application Next.js 15 avec Auth.js v5 : Email, OAuth et contrôle des rôles
Apprenez à ajouter une authentification prête pour la production à votre application Next.js 15 avec Auth.js v5. Ce guide complet couvre Google OAuth, les identifiants email/mot de passe, les routes protégées, le middleware et le contrôle d'accès basé sur les rôles.

Construire un Starter Kit SaaS avec Next.js 15, Stripe et Auth.js v5
Apprenez a construire une application SaaS prete pour la production avec Next.js 15, Stripe pour la facturation par abonnement, et Auth.js v5 pour l'authentification. Ce tutoriel pas a pas couvre la configuration du projet, la connexion OAuth, les plans tarifaires, la gestion des webhooks et les routes protegees.