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 COLUMNetRENAME COLUMNdans 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
TEXTetBLOBvolumineuses coûtent du temps de transfert, même via le canal HTTP. - Paginer avec
WHERE created_at < ? ORDER BY created_at DESC LIMIT 50au lieu de grandesOFFSETvaleurs. - After deleting a lot of data, run
VACUUMoccasionnellement 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 :
- Kysely avec Capacitor et SQLite: un query builder typé avec un petit driver personnalisé pour Fast SQL.
- Drizzle ORM avec Capacitor et SQLite: un schéma en TypeScript et des migrations générées.
- TypeORM avec Capacitor et SQLite: des décorateurs et des repositories.
- Options de stockage clé-valeur dans Capacitor: quand Preferences ou une table KV sur SQLite suffit.
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.