PGlite : exécuter PostgreSQL dans le navigateur avec WebAssembly

Noqta Team
Par Noqta Team ·

Chargement du lecteur de synthèse vocale...

Ce que vous allez apprendre

Dans ce tutoriel, vous découvrirez comment utiliser PGlite, un projet open source développé par ElectricSQL qui compile PostgreSQL en WebAssembly (WASM). À la fin de ce guide, vous serez capable de :

  • Exécuter une base de données PostgreSQL complète entièrement dans le navigateur — sans serveur
  • Persister les données entre les rechargements de page grâce à IndexedDB
  • Utiliser des requêtes réactives en temps réel qui mettent automatiquement à jour votre interface
  • Construire une application de tâches local-first avec React et TypeScript
  • Comprendre quand et pourquoi PGlite est le bon choix pour votre projet

Prérequis

Avant de commencer, assurez-vous de disposer de :

  • Node.js 20+ installé sur votre machine
  • Le gestionnaire de paquets npm ou pnpm
  • Des connaissances de base en React et TypeScript
  • Une familiarité avec SQL (SELECT, INSERT, UPDATE, DELETE)
  • Un navigateur moderne (Chrome, Firefox ou Edge)

Pourquoi PGlite ?

Les applications web traditionnelles nécessitent un serveur de base de données en backend. PGlite change cette équation en intégrant une instance PostgreSQL complète directement dans votre environnement JavaScript. Voici pourquoi cela change la donne :

  • Zéro infrastructure : pas de serveur de base de données à provisionner, configurer ou maintenir
  • Démarrage instantané : la base de données se lance en quelques millisecondes dans le navigateur
  • Support SQL complet : contrairement à IndexedDB ou localStorage, vous bénéficiez du vrai SQL avec jointures, index, opérateurs JSON et extensions
  • Hors ligne par défaut : les données résident dans le navigateur — votre application fonctionne sans Internet
  • Requêtes réactives : le système de requêtes en direct de PGlite pousse les mises à jour vers votre interface automatiquement
  • Léger : le bundle WASM fait environ 3 Mo compressé

PGlite est idéal pour le prototypage, les applications local-first, les outils embarqués, les extensions de navigateur et tout scénario nécessitant la puissance SQL sans la complexité serveur.

Étape 1 : créer un nouveau projet React

Commencez par créer un nouveau projet React avec Vite et TypeScript :

npm create vite@latest pglite-todo -- --template react-ts
cd pglite-todo

Installez les dépendances principales :

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

Le package @electric-sql/pglite contient le build PostgreSQL WASM, et @electric-sql/pglite-react fournit des hooks React pour les requêtes en direct et la gestion de la base de données.

Étape 2 : initialiser la base de données PGlite

Créez un nouveau fichier src/db.ts pour configurer votre instance de base de données :

// src/db.ts
import { PGlite } from "@electric-sql/pglite";
 
// Utiliser IndexedDB pour la persistance entre les rechargements
const db = new PGlite("idb://todo-app");
 
export default db;

Le préfixe idb:// indique à PGlite de stocker les données dans IndexedDB. Sans ce préfixe, les données existeraient uniquement en mémoire et disparaîtraient au rechargement. Les autres options de stockage incluent :

  • memory:// — en mémoire uniquement (idéal pour les tests)
  • idb://nom-base — persisté dans IndexedDB
  • Un chemin de fichier dans Node.js — persisté sur le système de fichiers

Étape 3 : créer le schéma de la base de données

Créez un fichier de migration src/migrate.ts qui configure la table des tâches :

// src/migrate.ts
import db from "./db";
 
export async function runMigrations() {
  await db.exec(`
    CREATE TABLE IF NOT EXISTS todos (
      id SERIAL PRIMARY KEY,
      title TEXT NOT NULL,
      completed BOOLEAN DEFAULT false,
      created_at TIMESTAMP DEFAULT NOW()
    );
  `);
}

Il s'agit de DDL PostgreSQL standard — PGlite supporte la même syntaxe SQL que vous utiliseriez avec un serveur Postgres classique. La clause IF NOT EXISTS garantit que la migration est idempotente et peut être exécutée en toute sécurité à chaque démarrage de l'application.

Étape 4 : construire le Provider PGlite

Enveloppez votre application avec un provider qui initialise la base de données avant de rendre les composants enfants. Créez src/PGliteProvider.tsx :

// src/PGliteProvider.tsx
import { PGliteProvider } from "@electric-sql/pglite-react";
import { useEffect, useState, type ReactNode } from "react";
import db from "./db";
import { runMigrations } from "./migrate";
 
export function DatabaseProvider({ children }: { children: ReactNode }) {
  const [ready, setReady] = useState(false);
 
  useEffect(() => {
    runMigrations().then(() => setReady(true));
  }, []);
 
  if (!ready) {
    return <div className="loading">Initialisation de la base de données...</div>;
  }
 
  return <PGliteProvider db={db}>{children}</PGliteProvider>;
}

Étape 5 : implémenter la liste de tâches avec les requêtes en direct

Créez maintenant le composant principal dans src/TodoApp.tsx. C'est ici que PGlite brille vraiment — le hook useLiveQuery re-rend automatiquement votre composant chaque fois que les données changent :

// src/TodoApp.tsx
import { useLiveQuery, usePGlite } from "@electric-sql/pglite-react";
import { useState } from "react";
 
interface Todo {
  id: number;
  title: string;
  completed: boolean;
  created_at: string;
}
 
export function TodoApp() {
  const db = usePGlite();
  const [newTitle, setNewTitle] = useState("");
 
  // Requête en direct — se met à jour automatiquement
  const todos = useLiveQuery<Todo>(
    "SELECT * FROM todos ORDER BY created_at DESC"
  );
 
  const addTodo = async () => {
    if (!newTitle.trim()) return;
    await db.exec("INSERT INTO todos (title) VALUES ($1)", [newTitle.trim()]);
    setNewTitle("");
  };
 
  const toggleTodo = async (id: number, completed: boolean) => {
    await db.exec("UPDATE todos SET completed = $1 WHERE id = $2", [
      !completed,
      id,
    ]);
  };
 
  const deleteTodo = async (id: number) => {
    await db.exec("DELETE FROM todos WHERE id = $1", [id]);
  };
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter") addTodo();
  };
 
  return (
    <div className="todo-app">
      <h1>Application de tâches PGlite</h1>
      <p className="subtitle">
        Propulsée par PostgreSQL fonctionnant dans votre navigateur via WebAssembly
      </p>
 
      <div className="input-row">
        <input
          type="text"
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Que faut-il faire ?"
        />
        <button onClick={addTodo}>Ajouter</button>
      </div>
 
      <ul className="todo-list">
        {todos?.rows.map((todo) => (
          <li key={todo.id} className={todo.completed ? "completed" : ""}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id, todo.completed)}
              />
              <span>{todo.title}</span>
            </label>
            <button className="delete" onClick={() => deleteTodo(todo.id)}>
              Supprimer
            </button>
          </li>
        ))}
      </ul>
 
      {todos?.rows.length === 0 && (
        <p className="empty">Aucune tâche pour le moment. Ajoutez-en une ci-dessus !</p>
      )}
    </div>
  );
}

Remarquez que vous ne rafraîchissez jamais manuellement les résultats des requêtes. Lorsque vous appelez db.exec pour insérer, mettre à jour ou supprimer une ligne, le hook useLiveQuery détecte le changement et re-rend le composant automatiquement. Cela fonctionne de manière similaire aux abonnements en temps réel de Supabase ou Firebase, mais tout se passe localement sans aucune latence réseau.

Étape 6 : assembler l'application

Mettez à jour src/App.tsx pour utiliser le provider de base de données et le composant de tâches :

// src/App.tsx
import { DatabaseProvider } from "./PGliteProvider";
import { TodoApp } from "./TodoApp";
import "./App.css";
 
function App() {
  return (
    <DatabaseProvider>
      <TodoApp />
    </DatabaseProvider>
  );
}
 
export default App;

Étape 7 : ajouter le style

Remplacez le contenu de src/App.css par un style soigné pour l'application de tâches :

/* src/App.css */
:root {
  font-family: system-ui, -apple-system, sans-serif;
  line-height: 1.6;
  color: #1a1a2e;
  background: #f0f0f5;
}
 
.loading {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #666;
}
 
.todo-app {
  max-width: 600px;
  margin: 2rem auto;
  padding: 2rem;
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
 
h1 {
  margin: 0 0 0.25rem;
  font-size: 1.8rem;
}
 
.subtitle {
  margin: 0 0 1.5rem;
  color: #888;
  font-size: 0.9rem;
}
 
.input-row {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
}
 
.input-row input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  font-size: 1rem;
  transition: border-color 0.2s;
}
 
.input-row input:focus {
  outline: none;
  border-color: #6c63ff;
}
 
.input-row button {
  padding: 0.75rem 1.5rem;
  background: #6c63ff;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  transition: background 0.2s;
}
 
.input-row button:hover {
  background: #5a52d5;
}
 
.todo-list {
  list-style: none;
  padding: 0;
  margin: 0;
}
 
.todo-list li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.75rem 0;
  border-bottom: 1px solid #f0f0f0;
}
 
.todo-list li label {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  cursor: pointer;
  flex: 1;
}
 
.todo-list li.completed span {
  text-decoration: line-through;
  color: #aaa;
}
 
.delete {
  background: none;
  border: none;
  color: #e74c3c;
  cursor: pointer;
  font-size: 0.85rem;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
}
 
.delete:hover {
  background: #ffeaea;
}
 
.empty {
  text-align: center;
  color: #999;
  padding: 2rem 0;
}

Étape 8 : lancer et tester l'application

Démarrez le serveur de développement :

npm run dev

Ouvrez votre navigateur à l'adresse http://localhost:5173. Vous devriez voir l'application de tâches. Testez les fonctionnalités suivantes :

  1. Ajoutez quelques tâches — elles apparaissent instantanément dans la liste
  2. Cochez et décochez des tâches — l'interface se met à jour en temps réel
  3. Rechargez la page — vos tâches persistent grâce au stockage IndexedDB
  4. Ouvrez les DevTools puis Application et IndexedDB — vous pouvez voir les fichiers de la base PGlite

Étape 9 : requêtes et fonctionnalités avancées

L'un des plus grands atouts de PGlite est sa compatibilité totale avec PostgreSQL. Ajoutons quelques fonctionnalités avancées pour démontrer cette puissance.

Recherche en texte intégral

Ajoutez une barre de recherche utilisant la recherche en texte intégral native de PostgreSQL avec tsvector :

// Ajoutez une fonction de recherche à votre TodoApp
const searchTodos = async (query: string) => {
  if (!query.trim()) return;
 
  const result = await db.query<Todo>(
    `SELECT * FROM todos
     WHERE to_tsvector('english', title) @@ plainto_tsquery('english', $1)
     ORDER BY created_at DESC`,
    [query]
  );
 
  return result.rows;
};

Statistiques agrégées

Interrogez les statistiques de vos tâches avec les agrégations SQL standard :

const stats = useLiveQuery<{
  total: number;
  completed: number;
  pending: number;
}>(`
  SELECT
    COUNT(*) as total,
    COUNT(*) FILTER (WHERE completed = true) as completed,
    COUNT(*) FILTER (WHERE completed = false) as pending
  FROM todos
`);

Opérations JSON

PGlite supporte les opérateurs JSONB de PostgreSQL, permettant le stockage de documents complexes :

// Stocker des métadonnées en JSONB
await db.exec(`
  ALTER TABLE todos ADD COLUMN IF NOT EXISTS
    metadata JSONB DEFAULT '{}'::jsonb
`);
 
// Requêter avec les opérateurs JSON
await db.exec(
  `UPDATE todos SET metadata = metadata || $1::jsonb WHERE id = $2`,
  [JSON.stringify({ priority: "high", tags: ["work"] }), todoId]
);

Étape 10 : utiliser PGlite dans Node.js

PGlite ne se limite pas au navigateur. Vous pouvez utiliser la même API dans Node.js pour des outils CLI, des scripts, des tests et des fonctions serverless :

// node-example.ts
import { PGlite } from "@electric-sql/pglite";
 
async function main() {
  // Dans Node.js, utilisez un chemin de fichier pour la persistance
  const db = new PGlite("./my-local-db");
 
  await db.exec(`
    CREATE TABLE IF NOT EXISTS users (
      id SERIAL PRIMARY KEY,
      name TEXT NOT NULL,
      email TEXT UNIQUE
    )
  `);
 
  await db.exec(
    "INSERT INTO users (name, email) VALUES ($1, $2) ON CONFLICT DO NOTHING",
    ["Alice", "alice@example.com"]
  );
 
  const result = await db.query("SELECT * FROM users");
  console.log(result.rows);
 
  await db.close();
}
 
main();

Exécutez-le avec :

npx tsx node-example.ts

Pas de Docker, pas de docker-compose, pas d'installation de Postgres — juste un seul package npm.

Étape 11 : tester avec PGlite

PGlite est excellent pour les tests. Au lieu de simuler votre base de données ou de lancer des conteneurs Docker dans votre CI, utilisez une instance PGlite en mémoire :

// __tests__/todo.test.ts
import { PGlite } from "@electric-sql/pglite";
import { describe, it, expect, beforeEach } from "vitest";
 
describe("Opérations sur les tâches", () => {
  let db: PGlite;
 
  beforeEach(async () => {
    // Base de données en mémoire fraîche pour chaque test
    db = new PGlite();
    await db.exec(`
      CREATE TABLE todos (
        id SERIAL PRIMARY KEY,
        title TEXT NOT NULL,
        completed BOOLEAN DEFAULT false
      )
    `);
  });
 
  it("devrait insérer une tâche", async () => {
    await db.exec("INSERT INTO todos (title) VALUES ($1)", ["Acheter du lait"]);
    const result = await db.query("SELECT * FROM todos");
    expect(result.rows).toHaveLength(1);
    expect(result.rows[0].title).toBe("Acheter du lait");
  });
 
  it("devrait basculer la complétion", async () => {
    await db.exec("INSERT INTO todos (title) VALUES ($1)", ["Exercice"]);
    await db.exec("UPDATE todos SET completed = true WHERE id = 1");
    const result = await db.query("SELECT completed FROM todos WHERE id = 1");
    expect(result.rows[0].completed).toBe(true);
  });
});

Cette approche vous donne un véritable comportement PostgreSQL dans les tests — pas de mocks, pas de Docker, et les tests s'exécutent en quelques millisecondes.

Résolution de problèmes

Échec du chargement WASM

Si le binaire WASM ne se charge pas, vérifiez que votre bundler est configuré pour gérer les fichiers .wasm. Avec Vite, cela fonctionne directement. Pour Webpack, vous pourriez avoir besoin de :

// webpack.config.js
module.exports = {
  experiments: {
    asyncWebAssembly: true,
  },
};

Les données ne persistent pas

Assurez-vous d'utiliser le préfixe idb:// dans le constructeur. Sans lui, PGlite utilise le stockage en mémoire par défaut :

// Persistant
const db = new PGlite("idb://my-app");
 
// En mémoire uniquement (données perdues au rechargement)
const db = new PGlite();

Taille du bundle importante

Le binaire WASM de PGlite fait environ 3 Mo compressé. Pour améliorer le temps de chargement initial :

  • Utilisez les imports dynamiques pour charger PGlite de manière différée
  • Affichez un indicateur de chargement pendant l'initialisation du WASM
  • Envisagez un Service Worker pour mettre en cache le binaire WASM

Erreurs SharedArrayBuffer

Certaines fonctionnalités de PGlite nécessitent SharedArrayBuffer, qui requiert des en-têtes CORS spécifiques :

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Dans Vite, ajoutez ceci à votre configuration :

// vite.config.ts
export default defineConfig({
  plugins: [react()],
  server: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
});

Quand utiliser PGlite

PGlite est un excellent choix pour :

  • Le prototypage : passez directement à la construction sans configurer de base de données
  • Les applications local-first : listes de tâches, prise de notes, outils personnels
  • Les extensions de navigateur : base de données embarquée avec toute la puissance SQL
  • Les tests : remplacez les bases de données Docker par des instances instantanées en mémoire
  • Les fonctions edge : exécutez Postgres dans des environnements serverless sans connexions externes
  • Les applications hors ligne : PWA nécessitant un stockage de données structuré

Envisagez un serveur de base de données traditionnel quand vous avez besoin de :

  • Accès multi-utilisateurs à des données partagées
  • Des bases de données de plus de quelques centaines de Mo
  • Validation des données et contrôle d'accès côté serveur
  • Réplication et haute disponibilité

Prochaines étapes

Maintenant que PGlite fonctionne dans votre navigateur, voici quelques pistes pour approfondir :

  • Ajoutez la synchronisation ElectricSQL : combinez PGlite avec ElectricSQL pour synchroniser votre base locale avec une instance Postgres côté serveur
  • Construisez une PWA : ajoutez un Service Worker pour rendre votre application entièrement utilisable hors ligne
  • Utilisez les extensions PGlite : PGlite supporte des extensions comme pgvector pour la recherche par similarité vectorielle directement dans le navigateur
  • Explorez le support multi-onglets : utilisez la coordination multi-onglets de PGlite pour partager une base de données entre les onglets du navigateur

Conclusion

PGlite représente un changement de paradigme dans notre conception des bases de données pour les applications web. En compilant PostgreSQL en WebAssembly, il apporte toute la puissance du SQL — jointures, index, recherche en texte intégral, JSONB et bien plus — directement dans le navigateur sans aucune infrastructure serveur.

Dans ce tutoriel, vous avez construit une application de tâches complète qui persiste les données localement, réagit automatiquement aux changements et fonctionne entièrement côté client. Vous avez également exploré des fonctionnalités avancées comme la recherche en texte intégral, les opérations JSON et les stratégies de test.

Le mouvement local-first prend de l'ampleur, et PGlite est à son avant-garde. Que vous construisiez un prototype, une extension de navigateur ou une application hors ligne, PGlite vous offre la puissance de base de données dont vous avez besoin sans la charge opérationnelle.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur 9 Les Bases de Laravel 11 : Templates Blade.

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