Para usar SQLite en una app de Capacitor, instala un plugin nativo de SQLite como @capgo/capacitor-fast-sql, abre una base de datos una vez al iniciar, crea tu esquema con migraciones versionadas y luego lee y escribe con SQL parametrizado. Esta guía recorre toda esa configuración en Capacitor 8 para iOS, Android y la web, incluidas transacciones, cifrado, ubicaciones de archivos y los errores con los que más te vas a encontrar.
Si solo necesitas unos pocos ajustes, SQLite es excesivo. Si guardas listas, registros o cualquier cosa que quieras filtrar, ordenar o sincronizar, es la herramienta adecuada.
Cuándo SQLite es la opción de almacenamiento adecuada
Las apps de Capacitor tienen varios lugares donde guardar datos. Cada uno resuelve problemas distintos:
| Almacenamiento | Ideal para | Límites |
|---|---|---|
localStorage / IndexedDB |
Caché temporal | Vive dentro del WebView, el SO puede borrarlo y no hay consultas reales en localStorage |
@capacitor/preferences |
Algunos ajustes pequeños (tema, idioma) | Solo valores de texto, no está pensado para datos grandes, sin cifrar |
| Almacenamiento en Keychain / Keystore | Tokens, claves de cifrado | Solo secretos pequeños |
Archivos (@capacitor/filesystem) |
Imágenes, PDFs, exportaciones | Sin consultas |
| SQLite | Registros, datos offline, colas de sync, búsqueda | Gestionas el esquema y las migraciones |
Elige SQLite si necesitas cualquiera de estas cosas: datos offline-first, más de un par de cientos de registros, filtros y ordenación, relaciones entre tablas, escrituras atómicas de varios pasos o cifrado en reposo para datos estructurados.
Cómo elegir un plugin SQLite de Capacitor en 2026
Hay tres opciones realistas para SQLite nativo en Capacitor 8:
@capgo/capacitor-fast-sql |
@capacitor-community/sqlite |
@capgo/capacitor-data-storage-sqlite |
|
|---|---|---|---|
| Propósito | Base de datos SQL general | Base de datos SQL general | Almacén de clave-valor de cadenas sobre SQLite |
| Plataformas | iOS, Android, Web | iOS, Android, Web, Electron | iOS, Android, Web, Electron |
| Motor web | SQLite Wasm + OPFS oficial | jeep-sqlite (sql.js + IndexedDB) | IndexedDB |
| Transporte de datos | Canal HTTP local, evita el bridge | bridge de Capacitor | bridge de Capacitor |
| Cifrado | SQLCipher opcional (iOS, Android) | SQLCipher siempre incluido | SQLCipher, passphrase en la config |
| Formato de fila | Objetos indexados por columna | Objetos indexados por columna | Cadenas |
| Soporte de ORM | Drivers personalizados (Kysely, Drizzle) | Driver integrado de TypeORM | Ninguno |
Esta guía usa Fast SQL. La mantiene Capgo, sigue las versiones principales de Capacitor (plugin v8 para Capacitor 8), mantiene SQLCipher como opt-in y evita serializar result sets grandes a través del bridge. Si ya usas el plugin de la comunidad, lee nuestra guía de comparación y migración primero.
Instalar Fast SQL
bun add @capgo/capacitor-fast-sql
bunx cap sync
Fast SQL inicia un pequeño servidor HTTP vinculado a localhost y le envía el SQL con un bearer token por conexión. Ambas plataformas bloquean HTTP en texto plano por defecto, así que permítelo solo para loopback.
iOS: permitir red local
Añade esto a ios/App/App/Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
NSAllowsLocalNetworking solo cubre loopback y direcciones locales. No relaja App Transport Security para tus llamadas reales a API.
Android: permitir cleartext a localhost
Crear 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>
Referéncialo en android/app/src/main/AndroidManifest.xml:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
Si ya tienes una configuración de seguridad de red (por ejemplo, para SSL pinning), añade el domain-config bloque en lugar de crear un segundo archivo.
Abre la base de datos una vez
Crea un único módulo que gestione la conexión. Abrir la misma base de datos repetidamente es ineficiente, y necesitas un solo lugar para ejecutar migraciones.
// 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 y performancePresets están disponibles desde la versión 8.0.49 del plugin. performancePresets también activa foreign_keys, que SQLite deja desactivado por defecto, por lo que ON DELETE CASCADE funciona realmente.
Llama a getDb() donde necesites la base de datos. La primera llamada abre y migra; las siguientes reutilizan la misma promesa. Si abrir o migrar falla, la siguiente llamada lo intenta de nuevo.
Migraciones de esquema con PRAGMA user_version
Fast SQL te da SQL puro, no un framework de migraciones, y eso está bien porque SQLite ya tiene un campo de versión en la cabecera del archivo: PRAGMA user_version. Mantén un array ordenado de pasos y aplica solo lo que falte.
// 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}`);
});
}
}
Reglas para mantener esto seguro:
- Una sentencia SQL por
execute()call. Ambas implementaciones nativas preparan solo la primera instrucción de una cadena, por lo que"CREATE ...; CREATE ..."ejecuta silenciosamente una de ellas. - Cada versión se ejecuta en una transacción, por lo que un fallo a mitad de camino deja la base de datos en la versión anterior y el paso se vuelve a ejecutar en el próximo inicio.
- Preferimos cambios aditivos (nuevas tablas, nuevas columnas nulas). SQLite admite
ALTER TABLE ... DROP COLUMNyRENAME COLUMNen versiones recientes, pero el SQLite del sistema en versiones antiguas de Android puede estar desactualizado, por lo que la forma portable de remodelar una tabla es: crear nueva tabla, copiar, eliminar la antigua y renombrar.
Migraciones y live updates
Si envías cambios de JavaScript con actualizaciones Live Update de Capgo, tu migración code se envía con el bundle. Eso es conveniente, ya que un cambio de esquema no requiere una publicación en la tienda. También significa que un rollback puede colocar un bundle más antiguo sobre un esquema más nuevo. Mantén las migraciones compatibles hacia atrás (añade columnas, no las renombres ni las elimine en la misma versión que deja de usarlas) y un bundle más antiguo seguirá funcionando.
Lectura y escritura de datos
SQLConnection te da cuatro métodos que usarás todo el tiempo:
| Método | Devuelve | Se usa para |
|---|---|---|
query(sql, params) |
SQLRow[] |
Lecturas SELECT y PRAGMA |
run(sql, params) |
{ rowsAffected, insertId } |
INSERT, UPDATE, DELETE |
execute(sql, params) |
{ rows, rowsAffected, insertId } |
Cualquier cosa, resultado completo |
executeBatch(ops) |
SQLResult[] |
Muchas sentencias en un solo viaje |
Siempre vincula valores con ? placeholders. Nunca construyas SQL con concatenación de cadenas a partir de la entrada del usuario.
// 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;
}
Las filas devuelven objetos planos con claves por nombre de columna, por lo que no hay un paso de mapeo de columnas/valores. SQLite no tiene tipo booleano, así que almacena 1 y 0 y convierte en el borde de tu capa de datos, como setPinned hace.
Transacciones y escrituras por lotes
Envuelve cada escritura de varios pasos en una transacción. Mantiene los datos consistentes y es mucho más rápido, porque SQLite sincroniza con el disco una vez por commit en lugar de una vez por sentencia.
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() commits cuando el callback se resuelve y hace rollback si lanza una excepción. Si necesitas control manual, también hay beginTransaction(), commit() y rollback() en la conexión.
Todas las llamadas comparten una única conexión nativa por base de datos. Mientras una transacción está abierta, otras code que escriban en la misma base de datos se ejecutan dentro de ella. Mantén las transacciones cortas y evita esperar peticiones de red dentro de ellas.
Almacenar datos binarios
Pasa un Uint8Array y obtienes un Uint8Array atrás. El plugin lo codifica en base64 para su transporte y almacena un BLOB real.
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;
Mantén los medios grandes (fotos, videos) como archivos y almacena solo la ruta en SQLite. Los BLOBs son adecuados para miniaturas, adjuntos pequeños y embeddings.
Cifrar la base de datos
Fast SQL admite SQLCipher en iOS y Android. Es opcional, por lo que las apps que no lo necesiten no lo incluyen:
const db = await FastSQL.connect({
database: 'vault',
encrypted: true,
encryptionKey: keyFromSecureStorage,
});
En Android, añades net.zetetic:sqlcipher-android to your app’s build.gradlede tu app. En iOS, cambias el pod a la CapgoCapacitorFastSql/SQLCipher subspec. La generación de la clave, su almacenamiento en Keychain o Keystore, el cifrado de una base de datos existente en texto plano y la rotación de claves se explican paso a paso en Cómo cifrar una base de datos SQLite en Capacitor. El cifrado no está disponible en la web.
Soporte web
En la web, Fast SQL utiliza la compilación oficial de @sqlite.org/sqlite-wasm y guarda el archivo en el Origin Private File System (OPFS). Es un archivo SQLite real, no una copia en memoria que se vuelca en IndexedDB.
OPFS requiere aislamiento cross-origin, por lo que tu servidor de desarrollo y tu host de producción deben enviar dos cabeceras:
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'],
},
});
Si faltan los headers, el plugin registra un warning y recurre a una base de datos no persistente: todo funciona hasta que recargas. CapgoCapacitorFastSql.configureWeb({ useOpfs, worker }) te permite cambiar los valores por defecto antes del primer connect(), y no hace nada en nativo.
require-corp también bloquea imágenes y scripts cross-origin que no envían headers CORP o CORS, así que prueba los embeds de terceros al activarlo.
Ubicación del archivo de la base de datos
| Plataforma | Ubicación | Notas |
|---|---|---|
| iOS | Documents/<name>.db |
Incluido por defecto en iCloud y en los backups del dispositivo |
| Android | files/<name>.db en el almacenamiento interno de la app |
Sujeto a las reglas de Android Auto Backup |
| Web | OPFS para el origen de la página | Se borra si el usuario limpia los datos del sitio |
Dos consecuencias prácticas. Primero, desinstalar la app borra el archivo en ambas plataformas móviles. Segundo, las copias de seguridad pueden restaurar el archivo de base de datos en un nuevo dispositivo. Eso suele ser lo que quieres, pero si la base de datos está cifrada con una clave que no viaja con la copia de seguridad, el archivo restaurado no se puede abrir. La guía de cifrado explica cómo manejarlo.
Checklist de rendimiento
- Activar
walMode. Los lectores dejan de bloquear al escritor. - Escribe en lotes dentro de una transacción con
executeBatch. Esta es la mayor ganancia para importaciones y sincronización. - Añade índices a las columnas que filtras o ordenas, y compruébalo con
EXPLAIN QUERY PLAN. - Selecciona solo las columnas que necesitas. Grandes
TEXTyBLOBaumentan el tiempo de transferencia incluso por el canal HTTP. - Usa paginación con
WHERE created_at < ? ORDER BY created_at DESC LIMIT 50en lugar de valores grandes deOFFSET. - Después de borrar muchos datos, ejecuta
VACUUMocasionalmente para reducir el archivo. Reescribe toda la base de datos, así que hazlo en momentos de inactividad.
Solución de problemas
SQL execution failed en iOS justo después de conectar. La solicitud HTTP local fue bloqueada. Comprueba que NSAllowsLocalNetworking está en el lugar correcto Info.plist y reconstruir la app nativa; un live reload no es suficiente.
CLEARTEXT communication to localhost not permitted en Android. Falta la configuración de seguridad de red o no se referencia en el manifest.
Solo se ejecutó la primera sentencia. Pasaste varias sentencias en un solo string. Sepáralas o usa executeBatch.
no such table después de una actualización. La migración no se ejecutó o se ejecutó contra otro nombre de base de datos. Registra PRAGMA user_version al iniciar y compáralo con tu array de pasos.
Un filtro sobre un valor booleano o calculado nunca coincide en Android. En consultas de lectura, la implementación de Android vincula los parámetros como texto. Las comparaciones contra una columna con afinidad INTEGER siguen funcionando porque SQLite convierte el texto, pero las comparaciones contra expresiones (por ejemplo, json_extract(...) = ? o count(*) > ?) comparan texto con un número. Pasa 1/0 en lugar de true/false, y usa CAST(? AS INTEGER) al comparar con una expresión.
INSERT ... RETURNING no devuelve filas en Android. En la versión actual del plugin, Android solo devuelve filas para sentencias que comiencen con SELECT, PRAGMA o EXPLAIN. Usa insertId de los run() y selecciona la fila después. Lo mismo ocurre con las consultas que empiezan con WITH: envuélvelas para que empiecen con SELECT, o pruébalas en Android cuanto antes.
Los datos desaparecen al recargar el navegador. OPFS no está disponible, casi siempre porque faltan los headers COOP/COEP en el host de despliegue.
Encryption is not available in this build en iOS. Pasaste encrypted: true sin cambiar a la subspec de SQLCipher.
Para profundizar
El SQL puro funciona para apps pequeñas. Con más de unas pocas tablas, una capa tipada ayuda:
- Kysely con Capacitor y SQLite: un query builder tipado con un driver custom ligero para Fast SQL.
- Drizzle ORM con Capacitor y SQLite: esquema en TypeScript y migraciones generadas.
- TypeORM con Capacitor y SQLite: decoradores y repositorios.
- Opciones de almacenamiento key-value en Capacitor: cuando Preferences o una tabla KV en SQLite es suficiente.
La referencia del plugin está en el Docs de Fast SQL y la página del plugin. Fast SQL requiere una recompilación nativa al añadirlo o cambiar su configuración. Si no quieres mantener un Mac para eso, Build de Capgo puede generar binarios firmados de iOS y Android en la nube. Desde abril de 2026, las subidas a la App Store deben compilarse con Xcode 26 o posterior, así que verifica tu máquina de compilación antes del próximo lanzamiento.