Les applications web modernes exigent des expériences synchronisées en temps réel. Les utilisateurs s'attendent à ce que les données se mettent à jour instantanément sur tous les appareils et que l'application continue de fonctionner hors ligne. Les approches traditionnelles — polling, WebSockets, gestion manuelle du cache — ajoutent une complexité considérable à votre code.
ElectricSQL (ou simplement Electric) résout ce problème grâce à un moteur de synchronisation open-source qui se positionne devant votre base de données PostgreSQL. Plutôt que d'écrire une logique de synchronisation complexe, vous définissez des Shapes — des abonnements déclaratifs qui décrivent quelles données streamer vers le client. Electric s'occupe du reste : mises à jour en temps réel, file d'attente hors ligne et fusion sans conflits.
Dans ce tutoriel, vous allez construire un gestionnaire de tâches collaboratif qui :
- Synchronise les tâches en temps réel sur plusieurs onglets du navigateur
- Fonctionne hors ligne et se resynchronise automatiquement au retour de la connexion
- Utilise
useOptimisticde React 19 pour un retour d'interface instantané - Tourne entièrement sur une infrastructure open-source (PostgreSQL + Electric)
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ et npm installés
- Docker et Docker Compose
- Connaissances de base de Next.js App Router et des hooks React
- Familiarité avec PostgreSQL et les bases du SQL
- Un éditeur de code (VS Code recommandé)
Qu'est-ce qu'ElectricSQL ?
Electric est un moteur de synchronisation créé par les auteurs de PGLite. Il exploite la réplication logique de PostgreSQL pour streamer les changements au niveau des lignes vers le frontend en temps réel via un protocole HTTP simple.
Concepts clés :
- Shapes : Une Shape est une requête live — elle indique à Electric quelles lignes et colonnes synchroniser avec le client. Considérez-la comme un abonnement à un sous-ensemble filtré de votre table PostgreSQL.
- Electric Server : Un proxy léger qui se connecte à PostgreSQL via la réplication logique et sert les Shapes en HTTP sur le port 3000.
@electric-sql/react: La bibliothèque cliente React exposant un hookuseShape()qui souscrit à une Shape et retourne des données réactives mises à jour automatiquement.
Ce qui distingue Electric des alternatives comme Supabase Realtime ou Convex :
- Aucun verrouillage fournisseur — fonctionne avec n'importe quelle base PostgreSQL standard
- Aucun code de synchronisation personnalisé — déclarez des Shapes, Electric fait le reste
- Protocole HTTP — fonctionne à travers les proxies, CDN et load balancers
- Se scale avec PostgreSQL — aucune infrastructure stateful supplémentaire
Étape 1 : Créer le projet Next.js
Initialisez un nouveau projet Next.js 15 :
npx create-next-app@latest electric-tasks --typescript --tailwind --app
cd electric-tasksInstallez les bibliothèques clientes Electric :
npm install @electric-sql/react @electric-sql/clientInstallez les packages utilitaires pour les mutations côté serveur :
npm install postgres uuid
npm install -D @types/uuidÉtape 2 : Lancer PostgreSQL et Electric avec Docker
Créez un fichier docker-compose.yml à la racine du projet :
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: electric_tasks
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
command:
- postgres
- -c
- wal_level=logical
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: postgresql://postgres:password@postgres:5432/electric_tasks
AUTH_MODE: insecure
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
postgres_data:Le flag wal_level=logical est obligatoire — Electric utilise la réplication logique de PostgreSQL pour suivre les changements au niveau des lignes.
Démarrez les deux services :
docker compose up -dVérifiez qu'Electric fonctionne en visitant http://localhost:3000/v1/health — vous devriez voir {"status":"ok"}.
Étape 3 : Créer le schéma de base de données
Créez db/schema.sql :
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Créez db/migrate.mjs pour appliquer le schéma :
import postgres from "postgres";
import { readFileSync } from "fs";
const sql = postgres(process.env.DATABASE_URL);
const schema = readFileSync("./db/schema.sql", "utf8");
await sql.unsafe(schema);
console.log("Schéma appliqué avec succès");
await sql.end();Créez .env.local :
DATABASE_URL=postgresql://postgres:password@localhost:5432/electric_tasks
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:3000Exécutez la migration :
node --env-file=.env.local db/migrate.mjsÉtape 4 : Définir les types TypeScript
Créez types/task.ts :
export interface Task {
id: string;
title: string;
completed: boolean;
created_at: string;
updated_at: string;
}Étape 5 : Créer les Server Actions pour les mutations
Créez app/actions.ts pour gérer les opérations d'écriture côté serveur :
"use server";
import postgres from "postgres";
import { v4 as uuidv4 } from "uuid";
const sql = postgres(process.env.DATABASE_URL!);
export async function createTask(title: string): Promise<void> {
await sql`
INSERT INTO tasks (id, title)
VALUES (${uuidv4()}, ${title})
`;
}
export async function toggleTask(id: string, completed: boolean): Promise<void> {
await sql`
UPDATE tasks
SET completed = ${completed},
updated_at = NOW()
WHERE id = ${id}
`;
}
export async function deleteTask(id: string): Promise<void> {
await sql`DELETE FROM tasks WHERE id = ${id}`;
}Notez l'absence d'appels revalidatePath — le moteur de synchronisation Electric diffuse les changements directement à tous les clients abonnés, rendant l'invalidation manuelle du cache inutile.
Étape 6 : Créer le hook Electric Shape
Créez hooks/useTasks.ts :
"use client";
import { useShape } from "@electric-sql/react";
import type { Task } from "@/types/task";
export function useTasks() {
const { data, isLoading, error } = useShape<Task>({
url: `${process.env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`,
params: {
table: "tasks",
order_by: "created_at",
},
});
return {
tasks: (data ?? []) as Task[],
isLoading,
error,
};
}Le hook useShape ouvre une connexion HTTP à long polling vers le serveur Electric. Lorsqu'une ligne de la table tasks change, Electric pousse le delta vers tous les clients connectés et le hook déclenche un re-rendu. Aucun code WebSocket complexe requis.
Étape 7 : Créer le composant TaskItem
Créez components/TaskItem.tsx :
"use client";
import { useOptimistic } from "react";
import { toggleTask, deleteTask } from "@/app/actions";
import type { Task } from "@/types/task";
export function TaskItem({ task }: { task: Task }) {
const [optimisticTask, applyOptimistic] = useOptimistic(task);
async function handleToggle() {
applyOptimistic({ ...task, completed: !task.completed });
await toggleTask(task.id, !task.completed);
}
return (
<div className="flex items-center gap-3 p-3 rounded-lg border bg-white shadow-sm">
<input
type="checkbox"
checked={optimisticTask.completed}
onChange={handleToggle}
className="h-4 w-4 cursor-pointer accent-blue-500"
/>
<span
className={`flex-1 text-sm ${
optimisticTask.completed ? "line-through text-gray-400" : "text-gray-700"
}`}
>
{task.title}
</span>
<button
onClick={() => deleteTask(task.id)}
className="text-xs text-red-400 hover:text-red-600 transition-colors"
>
Supprimer
</button>
</div>
);
}useOptimistic de React 19 met à jour la case à cocher instantanément avant la fin de la mutation côté serveur, éliminant la latence perçue.
Étape 8 : Créer le formulaire de tâche
Créez components/TaskForm.tsx :
"use client";
import { useRef, useTransition } from "react";
import { createTask } from "@/app/actions";
export function TaskForm() {
const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) {
const title = (formData.get("title") as string).trim();
if (!title) return;
startTransition(async () => {
await createTask(title);
if (inputRef.current) inputRef.current.value = "";
});
}
return (
<form action={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
name="title"
placeholder="Que faut-il faire ?"
disabled={isPending}
className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
required
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
{isPending ? "Ajout..." : "Ajouter"}
</button>
</form>
);
}Étape 9 : Indicateur de statut de synchronisation
Créez components/SyncStatus.tsx pour afficher l'état de connexion Electric :
"use client";
import { useTasks } from "@/hooks/useTasks";
export function SyncStatus() {
const { isLoading } = useTasks();
return (
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span
className={`h-2 w-2 rounded-full ${
isLoading ? "bg-yellow-400 animate-pulse" : "bg-green-400"
}`}
/>
{isLoading ? "Connexion..." : "En direct"}
</div>
);
}Étape 10 : Assembler la liste de tâches
Créez components/TaskList.tsx :
"use client";
import { useTasks } from "@/hooks/useTasks";
import { TaskItem } from "./TaskItem";
import { TaskForm } from "./TaskForm";
import { SyncStatus } from "./SyncStatus";
export function TaskList() {
const { tasks, isLoading, error } = useTasks();
const pending = tasks.filter((t) => !t.completed);
const done = tasks.filter((t) => t.completed);
return (
<div className="max-w-lg mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Tâches</h1>
<SyncStatus />
</div>
<TaskForm />
{error && (
<p className="text-sm text-red-500 bg-red-50 px-3 py-2 rounded-lg">
Impossible de se connecter à Electric. Assurez-vous que le serveur tourne sur le port 3000.
</p>
)}
{!error && (
<>
{pending.length > 0 && (
<section className="space-y-2">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">
En attente — {pending.length}
</p>
{pending.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</section>
)}
{done.length > 0 && (
<section className="space-y-2">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">
Terminé — {done.length}
</p>
{done.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</section>
)}
{tasks.length === 0 && !isLoading && (
<p className="text-sm text-gray-400 text-center py-8">
Aucune tâche pour le moment. Ajoutez-en une ci-dessus !
</p>
)}
</>
)}
</div>
);
}Étape 11 : Connecter la page principale
Mettez à jour app/page.tsx :
import { TaskList } from "@/components/TaskList";
export default function Home() {
return (
<main className="min-h-screen bg-gray-50 py-12">
<TaskList />
</main>
);
}Étape 12 : Tester la synchronisation en temps réel
Démarrez le serveur de développement :
npm run devNext.js utilise le port 3001 par défaut car Electric occupe déjà le port 3000.
Ouvrez http://localhost:3001 dans deux onglets séparés et essayez :
- Ajoutez une tâche dans le premier onglet — elle apparaît dans le second en environ 100 millisecondes
- Cochez la complétion — les deux onglets se mettent à jour simultanément
- Supprimez une tâche — disparaît partout instantanément
- Ouvrez les DevTools, allez dans Network, filtrez par
shape— observez les requêtes HTTP long polling qui alimentent la synchronisation
Étape 13 : Synchronisation partielle avec les filtres Shape
L'une des fonctionnalités les plus puissantes d'Electric est de ne synchroniser que les données dont l'utilisateur a besoin. Plutôt que de streamer toute la table, filtrez par identifiant utilisateur ou par statut :
export function useUserTasks(userId: string) {
const { data } = useShape<Task>({
url: `${process.env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`,
params: {
table: "tasks",
where: `user_id = '${userId}'`,
},
});
return (data ?? []) as Task[];
}C'est essentiel pour les applications multi-tenant — chaque utilisateur télécharge uniquement ses propres lignes, réduisant la bande passante et isolant les données sans middleware supplémentaire.
Étape 14 : Déployer en production
Pour la production, vous aurez besoin de :
- PostgreSQL avec la réplication logique activée — Neon, Supabase et Amazon RDS supportent cela ; activez avec
ALTER SYSTEM SET wal_level = logical; - Electric Server comme conteneur Docker sur Fly.io, Railway ou votre propre VPS
- Next.js déployé sur Vercel ou tout hébergeur Node.js
Configuration Electric pour la production avec authentification JWT :
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: ${DATABASE_URL}
AUTH_MODE: jwt
AUTH_JWT_ALG: ES256
AUTH_JWT_KEY: ${ELECTRIC_JWT_PUBLIC_KEY}
ports:
- "3000:3000"En production, remplacez l'auth insecure par JWT. Vos routes API Next.js émettent des JWT de courte durée qui autorisent l'accès à des Shapes spécifiques, empêchant les clients d'accéder aux données qui ne leur appartiennent pas.
Liste de contrôle avant le lancement
Vérifiez les points suivants avant de déployer :
- Les tâches apparaissent dans le second onglet dans les 200 millisecondes suivant leur création
- Basculer la complétion met à jour tous les onglets ouverts en temps réel
- Rafraîchir la page charge les tâches sans clignotement visible
- Couper et rétablir la connexion réseau synchronise les mutations en attente
- Les filtres Shape ne retournent que les lignes correspondantes
Résolution des problèmes courants
Le conteneur Electric se ferme immédiatement : Vérifiez que wal_level dans PostgreSQL est bien logical. Contrôlez avec psql -c "SHOW wal_level;" — doit retourner logical, pas replica ni minimal.
useShape retourne un tableau vide : Confirmez que NEXT_PUBLIC_ELECTRIC_URL dans .env.local est correct et exposé au client. Vérifiez l'onglet Network du navigateur pour les requêtes /v1/shape en échec.
Erreurs CORS dans le navigateur : Configurez la variable d'environnement ALLOW_ORIGIN dans Electric, ou proxifiez Electric via les rewrites Next.js :
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: "/electric/:path*",
destination: "http://localhost:3000/:path*",
},
];
},
};Puis mettez à jour NEXT_PUBLIC_ELECTRIC_URL en /electric.
Prochaines étapes
Maintenant que la synchronisation en temps réel fonctionne, envisagez ces améliorations :
- Ajouter une authentification avec Better Auth ou Clerk et émettre des JWT Electric par utilisateur
- Multi-tenancy — ajoutez une colonne
workspace_idet filtrez les Shapes par tenant - Indicateurs de présence — combinez les données Electric avec une petite couche WebSocket pour les positions de curseurs
- Mutations hors ligne — utilisez la fonctionnalité d'écriture à venir d'Electric pour mettre en buffer les mutations localement
Conclusion
ElectricSQL transforme la complexité des applications en temps réel en un modèle déclaratif simple. Au lieu de gérer des connexions WebSocket, implémenter une logique de reconnexion et écrire des stratégies de fusion, vous définissez des Shapes et laissez Electric faire le travail lourd.
L'architecture est propre : les Server Actions Next.js gèrent les écritures sur PostgreSQL, le moteur de synchronisation Electric propage chaque changement à tous les clients abonnés en temps réel, et useShape délivre un flux de données réactif à vos composants. La totalité de la stack est open-source, auto-hébergeable et construite sur le PostgreSQL que vous connaissez déjà.
Pour les équipes qui migrent depuis des solutions à base de polling, l'amélioration des performances perçues — et la réduction drastique du code de synchronisation — font d'ElectricSQL l'un des ajouts les plus impactants pour une stack Next.js moderne en 2026.