Per usare SQLite in un'app Capacitor, installa un plugin SQLite nativo come @capgo/capacitor-fast-sql, apri un database una volta all'avvio, crea lo schema con migrazioni versionate, poi leggi e scrivi con SQL parametrizzato. Questa guida illustra l'intero setup su Capacitor 8 per iOS, Android e web, incluse transazioni, crittografia, posizioni dei file e gli errori che incontrerai più spesso.
Se ti servono solo poche impostazioni, SQLite è eccessivo. Se invece devi salvare liste, record o qualsiasi cosa da filtrare, ordinare o sincronizzare, è lo strumento giusto.
Quando SQLite è la scelta giusta per lo storage
Le app Capacitor hanno diversi posti dove salvare i dati. Ognuno risolve problemi diversi:
| Storage | Ideale per | Limiti |
|---|---|---|
localStorage / IndexedDB |
Cache temporanea | Risiede nella WebView, l'OS può eliminarla, niente query reali in localStorage |
@capacitor/preferences |
Poche piccole impostazioni (tema, lingua) | Solo valori stringa, non adatto a grandi dati, non criptato |
| Storage Keychain / Keystore | Token, chiavi di cifratura | Solo piccoli segreti |
File (@capacitor/filesystem) |
Immagini, PDF, export | Nessuna query |
| SQLite | Record, dati offline, code di sync, ricerca | Gestisci tu schema e migrazioni |
Scegli SQLite se ti servono: approccio offline-first, più di qualche centinaio di record, filtri e ordinamento, relazioni tra tabelle, scritture atomiche multi-step o cifratura a riposo per dati strutturati.
Scegliere un plugin SQLite Capacitor nel 2026
Ci sono tre opzioni realistiche per SQLite nativo su Capacitor 8:
@capgo/capacitor-fast-sql |
@capacitor-community/sqlite |
@capgo/capacitor-data-storage-sqlite |
|
|---|---|---|---|
| Scopo | Database SQL generico | Database SQL generico | Store chiave-valore su SQLite |
| Piattaforme | iOS, Android, Web | iOS, Android, Web, Electron | iOS, Android, Web, Electron |
| Motore web | SQLite Wasm + OPFS ufficiale | jeep-sqlite (sql.js + IndexedDB) | IndexedDB |
| Trasporto dati | Canale HTTP locale, bypassa il bridge | bridge Capacitor | bridge Capacitor |
| Crittografia | SQLCipher opzionale (iOS, Android) | SQLCipher sempre incluso nel bundle | SQLCipher, passphrase nella configurazione |
| Formato riga | Oggetti con chiavi per colonna | Oggetti con chiavi per colonna | Stringhe |
| Supporto ORM | Driver personalizzati (Kysely, Drizzle) | Driver integrato di TypeORM | Nessuna |
Questa guida utilizza Fast SQL. È mantenuto da Capgo, segue Capacitor versioni major (plugin v8 per Capacitor 8), mantiene SQLCipher opzionale ed evita la serializzazione di grandi set di risultati tramite il bridge. Se utilizzi già il plugin della community, leggi la nostra guida alla comparazione e migrazione prima.
Installa Fast SQL
bun add @capgo/capacitor-fast-sql
bunx cap sync
Fast SQL avvia un piccolo server HTTP collegato a localhost e invia le query SQL tramite un bearer token per connessione. Entrambe le piattaforme bloccano l'HTTP in chiaro di default, quindi abilitalo solo per il loopback.
iOS: abilita la rete locale
Aggiungi questo a ios/App/App/Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
NSAllowsLocalNetworking copre solo gli indirizzi loopback e locali. Non disabilita l'App Transport Security per le tue chiamate API reali.
Android: abilita cleartext per localhost
Crea 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>
Richiamalo in android/app/src/main/AndroidManifest.xml:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
Se hai già una network security config (per l'SSL pinning, ad esempio), aggiungi il domain-config blocco invece di creare un secondo file.
Apri il database una sola volta
Crea un unico modulo che gestisca la connessione. Aprire ripetutamente lo stesso database è inefficiente, e avrai bisogno di un unico punto per eseguire le migrazioni.
// 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 e performancePresets sono disponibili dalla versione 8.0.49 del plugin. performancePresets attiva anche foreign_keys, che SQLite lascia disattivato di default, quindi ON DELETE CASCADE funziona davvero.
Chiama getDb() ovunque ti serva il database. La prima chiamata apre e migra, le chiamate successive riutilizzano la stessa promise. Se l'apertura o la migrazione falliscono, la chiamata successiva riprova.
Migrazioni dello schema con PRAGMA user_version
Fast SQL ti dà SQL raw, non un framework di migrazione, e va bene così perché SQLite ha già uno slot per la versione nell'header del file: PRAGMA user_version. Mantieni un array ordinato di step e applica solo quelli mancanti.
// 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}`);
});
}
}
Regole per tenerlo al sicuro:
- Una sola istruzione SQL per ogni
execute()chiamata. Entrambe le implementazioni native preparano solo la prima istruzione di una stringa, quindi"CREATE ...; CREATE ..."ne esegue una in silenzio. - Ogni versione viene eseguita in una transazione, quindi un crash a metà lascia il database alla versione precedente e lo step viene rieseguito al prossimo avvio.
- Preferisci modifiche additive (nuove tabelle, nuove colonne nullable). SQLite supporta
ALTER TABLE ... DROP COLUMNeRENAME COLUMNnelle versioni recenti, ma il SQLite di sistema su vecchie release Android potrebbe essere datato; il modo più portabile per modificare una tabella è creare una nuova tabella, copiare i dati, eliminare la vecchia e rinominare.
Migrazioni e live updates
Se distribuisci modifiche JavaScript con Live updates di Capgo, la tua migrazione code viene distribuita con il bundle. È comodo, perché un cambio di schema non richiede un nuovo rilascio sullo store. Significa anche che un rollback può applicare un bundle vecchio su uno schema più recente. Mantieni le migrazioni backward compatible (aggiungi colonne, non rinominarle o eliminarle nello stesso rilascio in cui smetti di usarle) e il vecchio bundle continuerà a funzionare.
Leggere e scrivere dati
SQLConnection ti offre quattro metodi che userai continuamente:
| Metodo | Restituisce | Usa per |
|---|---|---|
query(sql, params) |
SQLRow[] |
letture SELECT e PRAGMA |
run(sql, params) |
{ rowsAffected, insertId } |
INSERT, UPDATE, DELETE |
execute(sql, params) |
{ rows, rowsAffected, insertId } |
Qualsiasi cosa, risultato completo |
executeBatch(ops) |
SQLResult[] |
Molte istruzioni in un unico round trip |
Associa sempre i valori tramite ? placeholder. Non costruire mai SQL concatenando stringhe dall'input dell'utente.
// 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;
}
Le righe tornano come oggetti semplici indicizzati per nome di colonna, quindi non c'è nessuna fase di mapping columns/values. SQLite non ha un tipo boolean, quindi salva 1 e 0 e converti al confine del tuo data layer, come setPinned fa.
Transazioni e scritture batch
Racchiudi ogni scrittura multi-step in una transazione. Mantiene i dati coerenti ed è molto più veloce, perché SQLite sincronizza su disco una volta per commit invece che a ogni istruzione.
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() fa commit quando la callback si risolve e rollback se lancia un errore. Se ti serve un controllo manuale, c'è anche beginTransaction(), commit() e rollback() sulla connessione.
Tutte le chiamate condividono un'unica connessione nativa per database. Finché una transazione è aperta, ogni altro code che scrive sullo stesso database verrà eseguito al suo interno. Mantieni le transazioni brevi ed evita di attendere richieste di rete al loro interno.
Memorizzazione di dati binari
Passa un Uint8Array e otterrai un Uint8Array in risposta. Il plugin effettua la base64-encoding per il trasporto e memorizza un vero 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;
Conserva i file multimediali pesanti (foto, video) come file e salva solo il percorso in SQLite. I BLOB vanno bene per miniature, piccoli allegati ed embedding.
Crittografare il database
Fast SQL supporta SQLCipher su iOS e Android. È opzionale, quindi le app che non ne hanno bisogno non lo includono:
const db = await FastSQL.connect({
database: 'vault',
encrypted: true,
encryptionKey: keyFromSecureStorage,
});
Su Android aggiungi net.zetetic:sqlcipher-android alla tua app build.gradledella tua app. Su iOS, passa il pod alla CapgoCapacitorFastSql/SQLCipher subspec. La generazione della chiave, la memorizzazione nel Keychain o nel Keystore, la crittografia di un database esistente in chiaro e la rotazione delle chiavi sono trattate passo dopo passo in Come crittografare un database SQLite in Capacitor. La crittografia non è disponibile sul web.
Supporto web
Sul web, Fast SQL usa la build ufficiale @sqlite.org/sqlite-wasm e salva il file nell'Origin Private File System (OPFS). È un vero file SQLite, non una copia tenuta in memoria e riversata su IndexedDB.
L'OPFS richiede l'isolamento cross-origin, quindi il tuo dev server e l'host di produzione devono inviare due header:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Con 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'],
},
});
Se gli header mancano, il plugin logga un warning e passa a un database non persistente: tutto funziona finché non ricarichi. CapgoCapacitorFastSql.configureWeb({ useOpfs, worker }) ti permette di cambiare i default prima della prima connect(), ed è un no-op su native.
require-corp blocca anche immagini e script cross-origin che non inviano header CORP o CORS, quindi testa gli embed di terze parti quando lo attivi.
Dove risiede il file del database
| Piattaforma | Posizione | Note |
|---|---|---|
| iOS | Documents/<name>.db |
Incluso in iCloud e nei backup del dispositivo di default |
| Android | files/<name>.db nello storage interno dell'app |
Soggetto alle regole di Android Auto Backup |
| Web | OPFS per l'origine della pagina | Eliminato se l'utente cancella i dati del sito |
Due conseguenze pratiche. Primo: disinstallare l'app elimina il file su entrambe le piattaforme mobile. Secondo: i backup possono ripristinare il file del database su un nuovo dispositivo. Di solito è quello che vuoi, ma se il database è cifrato con una chiave che non è inclusa nel backup, il file ripristinato non può essere aperto. La guida alla crittografia spiega come gestirlo.
Checklist per le performance
- Attiva
walMode. I lettori non bloccano più lo scrittore. - Esegui scritture batch in una transazione con
executeBatch. Questo è il vantaggio principale per import e sync. - Aggiungi indici per le colonne su cui filtri o ordini, poi verifica con
EXPLAIN QUERY PLAN. - Seleziona solo le colonne necessarie. Colonne
TEXTeBLOBgrandi aumentano i tempi di trasferimento anche via HTTP. - Usa la paginazione con
WHERE created_at < ? ORDER BY created_at DESC LIMIT 50invece di grandiOFFSETvalori. - Dopo aver eliminato molti dati, esegui
VACUUMogni tanto per ridurre le dimensioni del file. Riscrive l'intero database, quindi fallo quando il dispositivo è inattivo.
Risoluzione problemi
SQL execution failed su iOS subito dopo la connessione. La richiesta HTTP locale è stata bloccata. Verifica che NSAllowsLocalNetworking sia nel corretto Info.plist e ricostruisci l'app nativa; un live reload non è sufficiente.
CLEARTEXT communication to localhost not permitted su Android. La configurazione di network security è mancante o non è referenziata nel manifest.
È stata eseguita solo la prima istruzione. Hai passato diverse istruzioni in una singola stringa. Dividile o usa executeBatch.
no such table dopo un aggiornamento. La migrazione non è stata eseguita o è stata applicata a un database con un nome diverso. Log PRAGMA user_version all'avvio e confrontali con il tuo array di step.
Un filtro su un valore booleano o calcolato non funziona mai su Android. Per le query di lettura, l'implementazione Android lega i parametri come testo. I confronti con una colonna con affinity INTEGER funzionano ancora perché SQLite converte il testo, ma i confronti con espressioni (ad esempio json_extract(...) = ? o count(*) > ?) confrontano testo con un numero. Passa 1/0 invece di true/false, e usa CAST(? AS INTEGER) quando confronti con un'espressione.
INSERT ... RETURNING non restituisce righe su Android. Nell'attuale versione del plugin, Android restituisce righe solo per le istruzioni che iniziano con SELECT, PRAGMA o EXPLAIN. Usa insertId da run() e selezionare la riga in seguito. Lo stesso vale per le query che iniziano con WITH: racchiudile in modo che inizino con SELECT, o testale subito su Android.
I dati scompaiono al ricaricamento nel browser. OPFS non è disponibile, quasi sempre perché mancano gli header COOP/COEP sull'host di deployment.
Encryption is not available in this build su iOS. Hai passato encrypted: true senza passare alla subspec di SQLCipher.
Approfondimenti
SQL raw va bene per app piccole. Quando hai più di qualche tabella, un layer tipizzato aiuta:
- Kysely con Capacitor e SQLite: query builder tipizzato con un piccolo driver custom per Fast SQL.
- Drizzle ORM con Capacitor e SQLite: schema in TypeScript, migrazioni generate.
- TypeORM con Capacitor e SQLite: decorator e repository.
- Opzioni di storage key-value in Capacitor: quando Preferences o una tabella KV su SQLite è sufficiente.
Il riferimento al plugin si trova nella Docs Fast SQL e il pagina del pluginSQL veloce richiede un Native Build quando lo aggiungi o ne cambi la configurazione. Se non vuoi gestire un Mac per questo, Build Capgo puoi generare i binari firmati per iOS e Android nel cloud. Da aprile 2026 gli upload su App Store dovranno usare Xcode 26 o successivi, quindi controlla la tua build machine prima del prossimo rilascio.