écrits/tutorial/2026/05
Tutorial13 mai 2026·28 min

Synchronisation PostgreSQL en temps réel avec ElectricSQL et Next.js 15

Apprenez à créer des applications offline-first et en temps réel avec ElectricSQL et Next.js 15. Ce tutoriel couvre la configuration du moteur de synchronisation Electric, l'utilisation de la Shape API et la création d'interfaces réactives qui restent synchronisées avec PostgreSQL sur tous les clients.

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 useOptimistic de 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 hook useShape() 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-tasks

Installez les bibliothèques clientes Electric :

npm install @electric-sql/react @electric-sql/client

Installez 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 -d

Vé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:3000

Exé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 dev

Next.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 :

  1. Ajoutez une tâche dans le premier onglet — elle apparaît dans le second en environ 100 millisecondes
  2. Cochez la complétion — les deux onglets se mettent à jour simultanément
  3. Supprimez une tâche — disparaît partout instantanément
  4. 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 :

  1. PostgreSQL avec la réplication logique activée — Neon, Supabase et Amazon RDS supportent cela ; activez avec ALTER SYSTEM SET wal_level = logical;
  2. Electric Server comme conteneur Docker sur Fly.io, Railway ou votre propre VPS
  3. 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_id et 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.