écrits/tutorial/2026/07
Tutorial15 juil. 2026·28 min

DuckDB-WASM dans le navigateur : créez un tableau de bord analytique 100% client avec Next.js 15

Apprenez à exécuter une base de données analytique SQL complète directement dans le navigateur avec DuckDB-WASM. Ce tutoriel pratique couvre la configuration de DuckDB-WASM dans un projet Next.js 15 App Router, l'interrogation de fichiers Parquet distants via HTTP, l'analyse de CSV importés par l'utilisateur et la création d'un tableau de bord analytique sans backend.

La pile analytique migre vers le client. Alors que l'écosystème lakehouse arrive à maturité — DuckLake 1.0 et Iceberg v3 sont sortis cet été — l'un des gagnants les plus surprenants est une base de données qui n'a besoin d'aucun serveur. DuckDB-WASM compile le moteur analytique complet de DuckDB en WebAssembly : le navigateur de vos utilisateurs peut scanner des fichiers Parquet, joindre des tables et exécuter des fonctions de fenêtrage sur des millions de lignes sans qu'une seule requête n'atteigne votre backend.

Dans ce tutoriel, vous allez construire un tableau de bord analytique entièrement côté client avec Next.js 15 et DuckDB-WASM. Vous interrogerez des fichiers Parquet distants via HTTP, permettrez aux utilisateurs d'importer leurs propres fichiers CSV et afficherez des résultats agrégés — le tout sans routes API, sans serveur de base de données et avec une confidentialité totale, puisque les données importées ne quittent jamais la machine de l'utilisateur.

Pourquoi exécuter une base de données dans le navigateur ?

Avant d'écrire du code, il faut comprendre quand cette architecture brille :

  • Confidentialité par défaut. Les données de l'utilisateur sont traitées localement. Pour les entreprises de la région MENA qui manipulent des données financières ou clients sensibles, cela élimine toute une catégorie de contraintes de conformité — rien n'est transmis, donc rien à sécuriser en transit ni au repos sur vos serveurs.
  • Zéro coût backend. Le gros du travail (scans, jointures, agrégations) s'exécute sur le CPU de l'utilisateur. Votre facture d'hébergement n'augmente pas avec le volume de requêtes.
  • Interactivité instantanée. Après le chargement initial des données, chaque changement de filtre est une requête locale. La latence passe de centaines de millisecondes à quelques millisecondes.
  • Du vrai SQL. DuckDB prend en charge les fonctions de fenêtrage, les CTE, PIVOT, les agrégations de listes et l'interrogation directe de fichiers Parquet, CSV et JSON. Ce n'est pas un jouet — c'est le même moteur qui alimente des pipelines de données en production.

La contrepartie : le bundle WASM pèse plusieurs mégaoctets (chargé paresseusement et mis en cache), et les jeux de données doivent tenir dans la mémoire du navigateur — un plafond pratique de quelques centaines de mégaoctets à environ deux gigaoctets selon l'appareil. Pour des tableaux de bord, des outils internes et des interfaces d'exploration de données, ce plafond pose rarement problème.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé
  • pnpm (ou npm/yarn) comme gestionnaire de paquets
  • Des bases en React et sur l'App Router de Next.js
  • Une familiarité avec SQL (SELECT, GROUP BY, JOIN)
  • Un éditeur de code (VS Code recommandé)

Ce que vous allez construire

Un tableau de bord analytique monopage qui :

  1. Démarre DuckDB-WASM dans un Web Worker pour garder le thread UI réactif
  2. Interroge un jeu de données Parquet distant via HTTP avec des requêtes de plage (sans téléchargement complet)
  3. Accepte l'import de fichiers CSV et les enregistre comme tables interrogeables
  4. Exécute du SQL agrégé et affiche les résultats dans des cartes de statistiques et un tableau
  5. Se déploie comme une application Next.js entièrement statique — sur n'importe quel CDN

Étape 1 : mise en place du projet

Créez un projet Next.js 15 avec TypeScript et Tailwind :

pnpm create next-app@latest duckdb-dashboard --typescript --tailwind --eslint --app --src-dir=false
cd duckdb-dashboard

Installez DuckDB-WASM et Apache Arrow (DuckDB renvoie les résultats de requêtes sous forme de tables Arrow) :

pnpm add @duckdb/duckdb-wasm apache-arrow

C'est toute la liste des dépendances. Pas d'ORM, pas de driver de base de données, pas de client API.

Étape 2 : initialiser DuckDB-WASM avec un singleton

DuckDB-WASM s'exécute dans un Web Worker. Son instanciation est relativement coûteuse (téléchargement du bundle WASM, création du worker), vous voulez donc exactement une instance partagée dans toute l'application. Créez lib/duckdb.ts :

// lib/duckdb.ts
import * as duckdb from "@duckdb/duckdb-wasm";
 
let dbPromise: Promise<duckdb.AsyncDuckDB> | null = null;
 
async function initDuckDB(): Promise<duckdb.AsyncDuckDB> {
  // Sélectionne le meilleur bundle pour ce navigateur (MVP vs EH vs COI)
  const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
  const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
 
  // Un Worker ne peut pas être créé cross-origin : on enveloppe donc
  // le script hébergé sur le CDN dans une URL Blob same-origin
  const workerUrl = URL.createObjectURL(
    new Blob([`importScripts("${bundle.mainWorker}");`], {
      type: "text/javascript",
    })
  );
 
  const worker = new Worker(workerUrl);
  const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING);
  const db = new duckdb.AsyncDuckDB(logger, worker);
 
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
  URL.revokeObjectURL(workerUrl);
 
  return db;
}
 
export function getDuckDB(): Promise<duckdb.AsyncDuckDB> {
  if (!dbPromise) {
    dbPromise = initDuckDB();
  }
  return dbPromise;
}

Quelques détails importants :

  • getJsDelivrBundles() renvoie les URL des binaires WASM hébergés sur jsDelivr. selectBundle() choisit la variante la plus rapide prise en charge par le navigateur — le build avec gestion d'exceptions pour les navigateurs modernes, le build MVP en secours.
  • L'astuce de l'URL Blob est nécessaire car les navigateurs refusent de créer un Worker depuis une URL cross-origin. Envelopper le script CDN dans importScripts au sein d'un Blob same-origin contourne proprement cette limite.
  • La variable dbPromise au niveau du module en fait un singleton : les doubles invocations du Strict Mode de React et les multiples composants appelant getDuckDB() partagent tous une seule instance.

Si vous préférez auto-héberger les bundles WASM plutôt que d'utiliser jsDelivr, copiez-les depuis node_modules vers votre répertoire public et construisez une carte de bundles manuelle. L'auto-hébergement évite une dépendance tierce en production et vous laisse définir vous-même des en-têtes de cache longs.

Étape 3 : un hook React pour les requêtes

Enveloppez ensuite la base de données dans un hook ergonomique. Créez hooks/useDuckDB.ts :

// hooks/useDuckDB.ts
"use client";
 
import { useCallback, useEffect, useRef, useState } from "react";
import type { AsyncDuckDB, AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
import { getDuckDB } from "@/lib/duckdb";
 
export function useDuckDB() {
  const [ready, setReady] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const dbRef = useRef<AsyncDuckDB | null>(null);
  const connRef = useRef<AsyncDuckDBConnection | null>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    getDuckDB()
      .then(async (db) => {
        if (cancelled) return;
        dbRef.current = db;
        connRef.current = await db.connect();
        setReady(true);
      })
      .catch((e) => setError(String(e)));
 
    return () => {
      cancelled = true;
    };
  }, []);
 
  const query = useCallback(async (sql: string) => {
    if (!connRef.current) throw new Error("DuckDB not ready");
    const result = await connRef.current.query(sql);
    // Table Arrow -> objets JS simples pour un rendu facile
    return result.toArray().map((row) => row.toJSON());
  }, []);
 
  return { ready, error, query, db: dbRef, conn: connRef };
}

Le helper query convertit les lignes Arrow en objets JavaScript simples avec toJSON(), ce qui est pratique pour le rendu. Pour de très grands jeux de résultats, on garderait la table Arrow et on lirait les colonnes directement — le format colonnaire d'Arrow est bien plus économe en mémoire — mais pour des résultats de taille tableau de bord (de quelques centaines à quelques milliers de lignes), les objets simples gardent le code React lisible.

Tout ce que DuckDB-WASM touche doit s'exécuter côté client. N'importez jamais lib/duckdb.ts depuis un Server Component — la directive "use client" sur les composants consommateurs est obligatoire, et un import dynamique avec ssr désactivé est le pattern le plus sûr pour l'intégration au niveau page.

Étape 4 : interroger des fichiers Parquet distants via HTTP

C'est ici que DuckDB-WASM devient réellement impressionnant. Il peut interroger un fichier Parquet hébergé sur n'importe quel serveur HTTP compatible CORS sans télécharger le fichier entier. DuckDB lit le footer du Parquet, détermine les groupes de lignes et colonnes nécessaires, et ne récupère que ces plages d'octets.

Essayez avec un jeu de données public :

const rows = await query(`
  SELECT
    passenger_count,
    COUNT(*) AS trips,
    ROUND(AVG(fare_amount), 2) AS avg_fare
  FROM 'https://blobs.duckdb.org/data/taxi_2019_04.parquet'
  GROUP BY passenger_count
  ORDER BY passenger_count
`);

C'est une agrégation complète sur un fichier Parquet distant, exécutée dans un onglet de navigateur. La première requête récupère les métadonnées et les colonnes nécessaires ; les requêtes suivantes sur le même fichier réutilisent les données déjà en mémoire.

Pour les fichiers que vous contrôlez, deux points comptent :

  1. Les en-têtes CORS. Le serveur doit envoyer Access-Control-Allow-Origin pour votre domaine et autoriser l'en-tête Range. Les object stores comme Cloudflare R2, S3 et DigitalOcean Spaces le permettent via la configuration CORS du bucket.
  2. La prise en charge des requêtes de plage. Presque tous les CDN et object stores gèrent les requêtes HTTP Range. Sans elles, DuckDB retombe sur un téléchargement complet.

Vous pouvez aussi enregistrer l'URL explicitement, ce qui donne au fichier un nom pratique pour un usage répété :

import * as duckdb from "@duckdb/duckdb-wasm";
 
await db.registerFileURL(
  "sales.parquet",
  "https://cdn.example.com/data/sales.parquet",
  duckdb.DuckDBDataProtocol.HTTP,
  false
);
 
const summary = await query(`
  SELECT region, SUM(amount) AS revenue
  FROM 'sales.parquet'
  GROUP BY region
  ORDER BY revenue DESC
`);

Publiez vos jeux de données de tableau de bord en Parquet plutôt qu'en CSV ou JSON. Le Parquet est colonnaire et compressé : une requête touchant 3 colonnes d'une table de 40 colonnes lit une fraction des octets — souvent 10 à 50 fois moins de transfert que le CSV équivalent.

Étape 5 : laisser les utilisateurs importer des fichiers CSV

Le second chemin de données concerne les fichiers fournis par l'utilisateur. registerFileBuffer place les octets d'un fichier dans le système de fichiers virtuel de DuckDB, et read_csv (avec détection automatique du schéma) le transforme en table. Créez components/CsvUploader.tsx :

// components/CsvUploader.tsx
"use client";
 
import { useState } from "react";
import { useDuckDB } from "@/hooks/useDuckDB";
 
export function CsvUploader({ onLoaded }: { onLoaded: (table: string) => void }) {
  const { ready, db, conn } = useDuckDB();
  const [status, setStatus] = useState<string>("");
 
  async function handleFile(file: File) {
    if (!db.current || !conn.current) return;
    setStatus("Chargement de " + file.name + "...");
 
    const buffer = new Uint8Array(await file.arrayBuffer());
    await db.current.registerFileBuffer(file.name, buffer);
 
    // Créer une vraie table pour éviter de re-parser le CSV à chaque requête
    await conn.current.query(`
      CREATE OR REPLACE TABLE uploaded AS
      SELECT * FROM read_csv('${file.name}', header = true, auto_detect = true)
    `);
 
    const count = await conn.current.query(
      `SELECT COUNT(*) AS n FROM uploaded`
    );
    setStatus(`${count.toArray()[0].n} lignes chargées`);
    onLoaded("uploaded");
  }
 
  return (
    <div className="rounded-lg border border-dashed p-6">
      <input
        type="file"
        accept=".csv"
        disabled={!ready}
        onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
      />
      <p className="mt-2 text-sm text-gray-500">{status}</p>
    </div>
  );
}

Deux décisions de conception à noter :

  • CREATE TABLE plutôt qu'interroger le fichier directement. Interroger read_csv(...) re-parse le CSV à chaque requête. Le matérialiser une fois dans une table native DuckDB rend toutes les requêtes suivantes nettement plus rapides.
  • Le fichier ne quitte jamais le navigateur. arrayBuffer() le lit en mémoire, registerFileBuffer le confie au système de fichiers WASM. Il n'y a aucun upload — un vrai argument commercial pour des outils manipulant paie, factures ou exports clients.

Étape 6 : construire la page du tableau de bord

Assemblez maintenant les pièces. Créez app/dashboard/page.tsx avec un composant client qui exécute des requêtes agrégées et affiche les résultats :

// app/dashboard/page.tsx
"use client";
 
import { useEffect, useState } from "react";
import { useDuckDB } from "@/hooks/useDuckDB";
import { CsvUploader } from "@/components/CsvUploader";
 
type Row = Record<string, unknown>;
 
export default function DashboardPage() {
  const { ready, error, query } = useDuckDB();
  const [rows, setRows] = useState<Row[]>([]);
  const [columns, setColumns] = useState<string[]>([]);
  const [sql, setSql] = useState(
    "SELECT * FROM 'https://blobs.duckdb.org/data/taxi_2019_04.parquet' LIMIT 20"
  );
  const [elapsed, setElapsed] = useState<number | null>(null);
 
  async function run(sqlText: string) {
    const start = performance.now();
    try {
      const result = (await query(sqlText)) as Row[];
      setElapsed(Math.round(performance.now() - start));
      setRows(result);
      setColumns(result.length ? Object.keys(result[0]) : []);
    } catch (e) {
      alert(String(e));
    }
  }
 
  useEffect(() => {
    if (ready) run(sql);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready]);
 
  if (error) return <p className="p-8 text-red-600">Échec DuckDB : {error}</p>;
  if (!ready) return <p className="p-8 animate-pulse">Démarrage de DuckDB-WASM...</p>;
 
  return (
    <main className="mx-auto max-w-5xl p-8 space-y-6">
      <h1 className="text-2xl font-bold">Analytique dans le navigateur</h1>
 
      <CsvUploader onLoaded={() => run("SELECT * FROM uploaded LIMIT 50")} />
 
      <textarea
        className="w-full rounded border p-3 font-mono text-sm"
        rows={4}
        value={sql}
        onChange={(e) => setSql(e.target.value)}
      />
      <div className="flex items-center gap-4">
        <button
          onClick={() => run(sql)}
          className="rounded bg-amber-500 px-4 py-2 font-medium text-white"
        >
          Exécuter la requête
        </button>
        {elapsed !== null && (
          <span className="text-sm text-gray-500">{elapsed} ms</span>
        )}
      </div>
 
      <div className="overflow-x-auto rounded border">
        <table className="w-full text-sm">
          <thead className="bg-gray-50">
            <tr>
              {columns.map((c) => (
                <th key={c} className="px-3 py-2 text-left font-semibold">
                  {c}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map((r, i) => (
              <tr key={i} className="border-t">
                {columns.map((c) => (
                  <td key={c} className="px-3 py-2">
                    {String(r[c])}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </main>
  );
}

Lancez le serveur de développement et ouvrez le tableau de bord :

pnpm dev
# ouvrez http://localhost:3000/dashboard

Vous avez maintenant un atelier SQL fonctionnel dans le navigateur. Tapez n'importe quel SQL DuckDB — fonctions de fenêtrage, PIVOT, summarize, compréhensions de listes — et il s'exécute localement.

Étape 7 : requêtes analytiques utiles

Pour que le tableau de bord ressemble à un produit plutôt qu'à une console SQL, branchez des requêtes prédéfinies sur des cartes de statistiques. Quelques patterns qui montrent la puissance analytique de DuckDB :

Profilage instantané du jeu de données — le SUMMARIZE de DuckDB donne min, max, moyenne, pourcentage de nulls et comptages distincts approximatifs pour chaque colonne en une seule instruction :

SUMMARIZE SELECT * FROM uploaded;

Agrégats temporels avec troncature de date :

SELECT
  date_trunc('month', order_date) AS month,
  COUNT(*) AS orders,
  SUM(amount) AS revenue,
  SUM(SUM(amount)) OVER (ORDER BY date_trunc('month', order_date)) AS cumulative
FROM uploaded
GROUP BY 1
ORDER BY 1;

Top N par groupe avec fonctions de fenêtrage :

SELECT * FROM (
  SELECT
    region,
    product,
    SUM(amount) AS revenue,
    ROW_NUMBER() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS rank
  FROM uploaded
  GROUP BY region, product
) WHERE rank <= 3;

Chacune de ces requêtes s'exécute en quelques millisecondes sur des centaines de milliers de lignes — sur un ordinateur portable, dans un onglet.

Tester votre implémentation

Vérifiez le flux complet de bout en bout :

  1. Vérification du démarrage : chargez /dashboard avec les DevTools ouverts. L'onglet Réseau doit montrer le chargement du bundle WASM une seule fois (environ 6 Mo, puis mis en cache). La page affiche « Démarrage de DuckDB-WASM... » puis l'aperçu du jeu de données taxi.
  2. Parquet distant : exécutez la requête d'agrégation de l'étape 4. Observez l'onglet Réseau — vous devez voir des requêtes de plage (réponses HTTP 206), pas un téléchargement complet du fichier.
  3. Import CSV : déposez n'importe quel CSV. Confirmez que le nombre de lignes apparaît et que SELECT * FROM uploaded LIMIT 5 renvoie vos données. Revérifiez l'onglet Réseau : aucune requête d'upload ne doit exister.
  4. Comportement mémoire : chargez un CSV plus volumineux (50-100 Mo) et exécutez SUMMARIZE. La mémoire de l'onglet augmente mais l'interface reste réactive, car DuckDB tourne dans son thread worker.

Dépannage

« Failed to construct Worker » ou erreurs CORS au démarrage. Vous instanciez probablement le worker directement depuis l'URL jsDelivr. Utilisez l'enveloppe en URL Blob de l'étape 2.

« window is not defined » pendant le build. Un Server Component importe du code DuckDB. Assurez-vous que chaque consommateur porte "use client" et envisagez un import dynamique avec ssr: false pour l'ensemble du tableau de bord.

La requête Parquet distante échoue avec une erreur HTTP. Le serveur d'hébergement n'envoie pas les en-têtes CORS, ou bloque l'en-tête Range. Testez d'abord avec le fichier d'exemple hébergé par DuckDB pour isoler un problème de configuration de bucket.

Les requêtes sur les CSV importés sont lentes. Vous relisez le CSV à chaque fois. Matérialisez-le avec CREATE TABLE comme montré à l'étape 5.

Les valeurs BigInt provoquent des erreurs de rendu. DuckDB renvoie les entiers 64 bits en BigInt, et JSON.stringify échoue sur BigInt. Castez en SQL (::DOUBLE ou ::VARCHAR) ou convertissez avec String() au rendu, comme le fait le composant tableau ci-dessus.

Prochaines étapes

  • Persistez les données entre les sessions avec la prise en charge OPFS de DuckDB (ouverture d'une base avec un chemin opfs://) pour que les jeux de données importés survivent au rechargement de la page.
  • Ajoutez des graphiques — associez les résultats de requêtes à une bibliothèque de graphiques et réexécutez les requêtes quand l'utilisateur change les filtres ; la latence locale rend le cross-filtering naturel.
  • Explorez l'angle lakehouse — notre guide récent sur DuckLake 1.0 et Iceberg v3 présente le pendant serveur de cette pile.
  • Comparez avec PGlite — pour des charges transactionnelles (plutôt qu'analytiques) dans le navigateur, consultez notre tutoriel sur PGlite, Postgres compilé en WASM.

Conclusion

Vous avez construit un tableau de bord analytique véritablement sans serveur : DuckDB-WASM démarre dans un Web Worker, scanne des fichiers Parquet distants avec des requêtes de plage, ingère des CSV utilisateur sans transférer un seul octet, et répond au SQL analytique en quelques millisecondes. Pour les tableaux de bord, les outils internes et les produits de données sensibles à la confidentialité, cette architecture supprime les parties les plus coûteuses de la pile traditionnelle — la couche API, le serveur de base de données et la charge de conformité liée à des données utilisateur que vous n'aviez en réalité jamais besoin de détenir.

Le vrai enseignement est le changement de modèle mental : le navigateur est désormais une cible de déploiement légitime pour les bases de données analytiques. Livrez le moteur au propriétaire des données, et l'essentiel de votre backend disparaît.