Aller au contenu principal

Comment utiliser SQLite dans une application Capacitor (guide 2026)

Apprenez à utiliser SQLite dans une application Capacitor : choisir un plugin, ouvrir une base de données, exécuter des migrations et des transactions, gérer le chiffrement et la compatibilité web, avec code fonctionnel.

Logo Capacitor pour un guide sur l’utilisation de SQLite dans une application Capacitor

Pour utiliser SQLite dans une application Capacitor, installez un plugin SQLite natif tel que @capgo/capacitor-fast-sql, ouvrez une base de données une seule fois au démarrage, créez votre schéma avec des migrations versionnées, puis effectuez des lectures et des écritures avec du SQL paramétré. Ce guide détaille cette configuration complète sur Capacitor 8 pour iOS, Android et le web, y compris les transactions, le chiffrement, les emplacements des fichiers et les erreurs les plus courantes.

Si vous n'avez besoin que de quelques paramètres, SQLite est surdimensionné. Si vous stockez des listes, des enregistrements ou tout ce que vous voulez filtrer, trier ou synchroniser, c'est l'outil idéal.

Quand SQLite est le bon choix de stockage

Capacitor apps have several places to put data. They solve different problems:

Stockage Idéal pour Limites
localStorage / IndexedDB Cache temporaire Réside dans la WebView, l'OS peut l'évincer, pas de vraies requêtes dans localStorage
@capacitor/preferences Quelques petits paramètres (thème, langue) Valeurs de type string uniquement, pas fait pour de gros volumes, non chiffré
Stockage Keychain / Keystore Tokens, clés de chiffrement Petits secrets uniquement
Fichiers (@capacitor/filesystem) Images, PDF, exports Aucune requête
SQLite Records, offline data, sync queues, search Vous gérez le schéma et les migrations

Choisissez SQLite si vous avez besoin de l’une de ces fonctionnalités : données offline-first, plus de quelques centaines d’enregistrements, filtres et tri, relations entre tables, écritures atomiques multi-étapes, ou chiffrement au repos pour des données structurées.

Choisir un plugin SQLite Capacitor en 2026

Il existe trois options réalistes pour SQLite natif sur Capacitor 8 :

@capgo/capacitor-fast-sql @capacitor-community/sqlite @capgo/capacitor-data-storage-sqlite
Objectif Base de données SQL générale Base de données SQL générale Stockage clé-valeur de chaînes sur SQLite
Plateformes iOS, Android, Web iOS, Android, Web, Electron iOS, Android, Web, Electron
Moteur web SQLite Wasm officiel + OPFS jeep-sqlite (sql.js + IndexedDB) IndexedDB
Transport de données Canal HTTP local, contourne le bridge bridge Capacitor bridge Capacitor
Chiffrement SQLCipher optionnel (iOS, Android) SQLCipher toujours inclus SQLCipher, phrase de passe dans la config
Format des lignes Objets indexés par colonne Objets indexés par colonne Chaînes
Support ORM Pilotes personnalisés (Kysely, Drizzle) Pilote intégré de TypeORM Aucun

Ce guide utilise Fast SQL. Il est maintenu par Capgo, suit les versions majeures de Capacitor (plugin v8 pour Capacitor 8), conserve SQLCipher en option et évite de sérialiser de grands ensembles de résultats via le bridge. Si vous utilisez déjà le plugin communautaire, lisez notre guide de comparaison et de migration d'abord.

Installer Fast SQL

bun add @capgo/capacitor-fast-sql
bunx cap sync

Fast SQL démarre un petit serveur HTTP lié à localhost et lui envoie des requêtes SQL via un token bearer par connexion. Les deux plateformes bloquent le HTTP en clair par défaut, autorisez-le donc uniquement pour le loopback.

iOS : autoriser le réseau local

Ajoutez ceci à ios/App/App/Info.plist:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>

NSAllowsLocalNetworking ne couvre que le loopback et les adresses locales. Il ne relâche pas App Transport Security pour vos appels API réels.

Android : autoriser le clair vers localhost

Créer android/app/src/main/res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">localhost</domain>
        <domain includeSubdomains="false">127.0.0.1</domain>
    </domain-config>
</network-security-config>

Référez-vous à android/app/src/main/AndroidManifest.xml:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>

Si vous avez déjà une configuration de sécurité réseau (par exemple pour le SSL pinning), ajoutez le domain-config bloc à celle-ci au lieu de créer un second fichier.

Ouvrir la base de données une seule fois

Créez un module unique qui gère la connexion. Ouvrir la même base de données à répétition est gaspille, et vous voulez un seul endroit pour exécuter les migrations.

// src/db/index.ts
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
import { migrate } from './migrate';

let dbPromise: Promise<SQLConnection> | null = null;

export function getDb(): Promise<SQLConnection> {
  if (!dbPromise) {
    dbPromise = (async () => {
      const db = await FastSQL.connect({
        database: 'notes',
        walMode: true, // PRAGMA journal_mode = WAL
        performancePresets: true, // synchronous=NORMAL, busy_timeout, foreign_keys=ON
      });
      await migrate(db);
      return db;
    })().catch((error) => {
      dbPromise = null; // let the next call retry instead of reusing the failure
      throw error;
    });
  }
  return dbPromise;
}

walMode et performancePresets sont disponibles à partir de la version 8.0.49 du plugin. performancePresets active également foreign_keys, que SQLite laisse désactivé par défaut, pour que ON DELETE CASCADE fonctionne réellement.

Appelez getDb() où vous en avez besoin. Le premier appel ouvre et migre la base, les suivants réutilisent la même promesse. Si l’ouverture ou la migration échoue, l’appel suivant réessaie.

Migrations de schéma avec PRAGMA user_version

Fast SQL vous donne du SQL brut, pas un framework de migration, ce qui est parfait car SQLite possède déjà un emplacement de version dans l’en-tête du fichier : PRAGMA user_version. Conservez un tableau ordonné d’étapes et appliquez uniquement ce qui manque.

// src/db/migrate.ts
import type { SQLConnection } from '@capgo/capacitor-fast-sql';

// Index 0 = version 1, index 1 = version 2, ...
// Never edit a step after it shipped. Add a new one.
const steps: string[][] = [
  [
    `CREATE TABLE IF NOT EXISTS notes (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       title TEXT NOT NULL,
       body TEXT,
       created_at INTEGER NOT NULL
     )`,
  ],
  [
    `ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`,
    `CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes (created_at)`,
  ],
];

export async function migrate(db: SQLConnection): Promise<void> {
  const rows = await db.query('PRAGMA user_version');
  const current = Number(rows[0]?.user_version ?? 0);

  for (let version = current; version < steps.length; version++) {
    await db.transaction(async (tx) => {
      for (const statement of steps[version]) {
        await tx.execute(statement);
      }
      // PRAGMA does not accept bound parameters, the value is a trusted integer
      await tx.execute(`PRAGMA user_version = ${version + 1}`);
    });
  }
}

Règles pour garder cela sûr :

  • Une instruction SQL par execute() call. Les deux implémentations natives ne préparent que la première instruction d'une chaîne, donc "CREATE ...; CREATE ..." exécute silencieusement l'une d'elles.
  • Chaque version s’exécute dans une transaction : en cas de crash en cours de route, la base reste à la version précédente et l’étape est relancée au prochain démarrage.
  • Privilégiez les modifications additives (nouvelles tables, nouvelles colonnes nullable). SQLite prend en charge ALTER TABLE ... DROP COLUMN et RENAME COLUMN dans les versions récentes, mais le SQLite système des anciennes versions d'Android peut être en retard, donc la méthode portable pour remodeler une table est : créer une nouvelle table, copier, supprimer l'ancienne, renommer.

Migrations et live updates

Si vous déployez des modifications JavaScript avec les live updates Capgo, votre migration code est livrée avec le bundle. C'est pratique, car un changement de schéma ne nécessite pas de release sur le store. Cela signifie aussi qu'un rollback peut placer un bundle plus ancien sur un schéma plus récent. Gardez les migrations rétrocompatibles (ajoutez des colonnes, ne les renommez pas ou ne les supprimez pas dans la même release qui cesse de les utiliser) et un bundle plus ancien continuera de fonctionner.

Lecture et écriture de données

SQLConnection vous donne quatre méthodes que vous utiliserez tout le temps :

Méthode Retourne À utiliser pour
query(sql, params) SQLRow[] Lectures SELECT et PRAGMA
run(sql, params) { rowsAffected, insertId } INSERT, UPDATE, DELETE
execute(sql, params) { rows, rowsAffected, insertId } Tout, résultat complet
executeBatch(ops) SQLResult[] Plusieurs instructions en un seul aller-retour

Liez toujours les valeurs avec des ? placeholders. Ne construisez jamais de SQL par concaténation de chaînes avec des entrées utilisateur.

// src/db/notes.ts
import { getDb } from './index';

export interface Note {
  id: number;
  title: string;
  body: string | null;
  created_at: number;
  pinned: number;
}

export async function addNote(title: string, body: string | null): Promise<number> {
  const db = await getDb();
  const { insertId } = await db.run(
    'INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?)',
    [title, body, Date.now()],
  );
  if (insertId == null) throw new Error('Insert did not return an id');
  return insertId;
}

export async function listNotes(search = ''): Promise<Note[]> {
  const db = await getDb();
  const rows = await db.query(
    'SELECT id, title, body, created_at, pinned FROM notes WHERE title LIKE ? ORDER BY pinned DESC, created_at DESC',
    [`%${search}%`],
  );
  return rows as unknown as Note[];
}

export async function setPinned(id: number, pinned: boolean): Promise<void> {
  const db = await getDb();
  await db.run('UPDATE notes SET pinned = ? WHERE id = ?', [pinned ? 1 : 0, id]);
}

export async function deleteNote(id: number): Promise<number> {
  const db = await getDb();
  const { rowsAffected } = await db.run('DELETE FROM notes WHERE id = ?', [id]);
  return rowsAffected;
}

Les lignes sont retournées sous forme d'objets simples indexés par le nom de la colonne, il n'y a donc pas d'étape de mapping colonnes/valeurs. SQLite n'a pas de type booléen, stockez donc 1 et 0 et convertissez aux frontières de votre couche de données, comme setPinned fait.

Transactions et écritures par lots

Encapsulez chaque écriture multi-étapes dans une transaction. Cela garantit la cohérence des données et c'est beaucoup plus rapide, car SQLite synchronise sur le disque une seule fois par commit au lieu d'une fois par instruction.

import { getDb } from './index';

export async function importNotes(items: { title: string; body: string }[]) {
  const db = await getDb();
  const now = Date.now();

  await db.transaction(async (tx) => {
    await tx.executeBatch(
      items.map((item) => ({
        statement: 'INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?)',
        params: [item.title, item.body, now],
      })),
    );
  });
}

transaction() commit quand le callback se résout et fait un rollback s'il lève une erreur. Si vous avez besoin d'un contrôle manuel, il existe aussi beginTransaction(), commit() et rollback() sur la connexion.

Tous les appels partagent une seule connexion native par base de données. Tant qu'une transaction est ouverte, les autres code qui écrivent dans la même base de données s'exécutent à l'intérieur de celle-ci. Gardez les transactions courtes et évitez d'attendre des requêtes réseau à l'intérieur.

Stockage de données binaires

Passez un Uint8Array et vous obtenez un Uint8Array en retour. Le plugin l'encode en base64 pour le transport et stocke un vrai BLOB.

const db = await getDb();
await db.execute('CREATE TABLE IF NOT EXISTS thumbnails (note_id INTEGER PRIMARY KEY, data BLOB)');
await db.run('INSERT OR REPLACE INTO thumbnails (note_id, data) VALUES (?, ?)', [noteId, bytes]);

const [row] = await db.query('SELECT data FROM thumbnails WHERE note_id = ?', [noteId]);
const image = row?.data as Uint8Array | undefined;

Gardez les gros médias (photos, vidéos) sous forme de fichiers et stockez uniquement le chemin dans SQLite. Les BLOB conviennent pour les miniatures, les petites pièces jointes et les embeddings.

Chiffrer la base de données

Fast SQL prend en charge SQLCipher sur iOS et Android. C’est une option facultative, donc les applications qui n’en ont pas besoin ne l’incluent pas :

const db = await FastSQL.connect({
  database: 'vault',
  encrypted: true,
  encryptionKey: keyFromSecureStorage,
});

Sur Android, vous ajoutez net.zetetic:sqlcipher-android à votre application build.gradle. Sur iOS, vous passez le pod à la CapgoCapacitorFastSql/SQLCipher sous-spécification. La génération de la clé, son stockage dans le Keychain ou le Keystore, le chiffrement d'une base de données en clair existante et la rotation des clés sont détaillés étape par étape dans Comment chiffrer une base de données SQLite dans Capacitor. Le chiffrement n'est pas disponible sur le web.

Support web

Sur le web, Fast SQL utilise le build officiel @sqlite.org/sqlite-wasm et stocke le fichier dans l'Origin Private File System (OPFS). C'est un véritable fichier SQLite, pas une copie gardée en mémoire puis vidée dans IndexedDB.

L'OPFS nécessite une isolation cross-origin, donc votre serveur de dev et votre hébergeur de production doivent envoyer deux headers :

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

Avec Vite :

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
  optimizeDeps: {
    exclude: ['@sqlite.org/sqlite-wasm'],
  },
});

Si les headers sont absents, le plugin logue un avertissement et bascule sur une base de données non persistante : tout fonctionne jusqu'au rechargement. CapgoCapacitorFastSql.configureWeb({ useOpfs, worker }) vous permet de modifier les valeurs par défaut avant le premier connect(), et n'a aucun effet sur le natif.

require-corp bloque également les images et scripts cross-origin qui n'envoient pas de headers CORP ou CORS, donc testez les intégrations tierces quand vous l'activez.

Emplacement du fichier de base de données

Plateforme Localisation Notes
iOS Documents/<name>.db Inclus par défaut dans les sauvegardes iCloud et de l’appareil
Android files/<name>.db dans le stockage interne de l’application Selon les règles de sauvegarde automatique d’Android
Le Web OPFS pour l’origine de la page Effacé si l’utilisateur supprime les données du site

Deux conséquences pratiques. Premièrement, la désinstallation de l'app supprime le fichier sur les deux plateformes mobiles. Deuxièmement, les sauvegardes peuvent restaurer le fichier de la base de données sur un nouvel appareil. C'est généralement ce que l'on souhaite, mais si la base est chiffrée avec une clé qui n'est pas incluse dans la sauvegarde, le fichier restauré ne pourra pas être ouvert. Le guide sur le chiffrement explique comment gérer cela.

Checklist de performance

  • Activez walMode. Les lecteurs ne bloquent plus l'écriture.
  • Regroupez les écritures dans une transaction avec executeBatch. C'est le plus gros gain pour les imports et la synchronisation.
  • Ajoutez des index sur les colonnes que vous filtrez ou triez, puis vérifiez avec EXPLAIN QUERY PLAN.
  • Ne sélectionnez que les colonnes nécessaires. Les colonnes TEXT et BLOB volumineuses coûtent du temps de transfert, même via le canal HTTP.
  • Paginer avec WHERE created_at < ? ORDER BY created_at DESC LIMIT 50 au lieu de grandes OFFSET valeurs.
  • After deleting a lot of data, run VACUUM occasionnellement pour réduire la taille du fichier. Il réécrit toute la base de données, à faire donc en période d’inactivité.

Troubleshooting

SQL execution failed sur iOS juste après la connexion. La requête HTTP locale a été bloquée. Vérifiez que NSAllowsLocalNetworking est dans le bon Info.plist et recompilez l'app native, un live reload ne suffit pas.

CLEARTEXT communication to localhost not permitted sur Android. La configuration de sécurité réseau est manquante ou n'est pas référencée dans le manifest.

Seule la première instruction a été exécutée. Vous avez passé plusieurs instructions dans une seule chaîne. Séparez-les ou utilisez executeBatch.

no such table après une mise à jour. La migration n'a pas tourné, ou elle a tourné sur un autre nom de base de données. Loguez PRAGMA user_version au démarrage et comparez avec votre tableau d'étapes.

Un filtre sur une valeur booléenne ou calculée ne correspond jamais sur Android. Pour les requêtes de lecture, l'implémentation Android lie les paramètres en tant que texte. Les comparaisons avec une colonne ayant une affinité INTEGER fonctionnent toujours car SQLite convertit le texte, mais les comparaisons avec des expressions (par exemple json_extract(...) = ? ou count(*) > ?) comparent du texte avec un nombre. Passez 1/0 au lieu de true/false, et utilisez CAST(? AS INTEGER) lors de la comparaison avec une expression.

INSERT ... RETURNING ne retourne aucune ligne sur Android. Dans la version actuelle du plugin, Android ne retourne des lignes que pour les instructions commençant par SELECT, PRAGMA ou EXPLAIN. Utilisez insertId depuis run() et sélectionnez la ligne ensuite. Il en va de même pour les requêtes commençant par WITH: enveloppez-les pour qu'elles commencent par SELECT, ou testez-les sur Android en amont.

Les données disparaissent au rechargement dans le navigateur. L'OPFS n'est pas disponible, presque toujours parce que les headers COOP/COEP manquent sur l'hôte déployé.

Encryption is not available in this build sur iOS. Vous avez passé encrypted: true sans passer à la subspec SQLCipher.

Pour aller plus loin

Le SQL brut convient aux petites apps. Dès que vous avez plus de quelques tables, une couche typée est utile :

La référence du plugin se trouve dans le Docs Fast SQL et le page du plugin. Fast SQL nécessite un build natif à chaque ajout ou modification de sa configuration. Si vous ne souhaitez pas maintenir un Mac à cet effet, Build Capgo peut générer des binaires iOS et Android signés dans le cloud. Depuis avril 2026, les uploads sur l’App Store doivent être réalisés avec Xcode 26 ou une version ultérieure. Vérifiez donc votre machine de build avant la prochaine release.

Live updates pour les apps Capacitor

Lorsqu'un bug de la couche web est en ligne, déployez le correctif via Capgo au lieu d'attendre des jours l'approbation des stores. Les utilisateurs reçoivent la mise à jour en arrière-plan, tandis que les changements natifs suivent le circuit de validation habituel.

Les derniers articles du blog

Capgo vous offre les meilleures informations pour créer une application mobile vraiment professionnelle.