Saltar al contenido principal

Cómo usar SQLite en una app de Capacitor (Guía 2026)

Aprende a usar SQLite en una app de Capacitor: elige un plugin, abre una base de datos, ejecuta migraciones y usa transacciones, cifrado y soporte web, con code funcional.

Logo de Capacitor para una guía sobre el uso de SQLite en una app de Capacitor

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 COLUMN y RENAME COLUMN en 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 TEXT y BLOB aumentan el tiempo de transferencia incluso por el canal HTTP.
  • Usa paginación con WHERE created_at < ? ORDER BY created_at DESC LIMIT 50 en lugar de valores grandes de OFFSET .
  • Después de borrar muchos datos, ejecuta VACUUM ocasionalmente 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:

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.

Live updates para apps Capacitor

Cuando un bug de la capa web está en producción, publica el fix a través de Capgo en lugar de esperar días a la aprobación de la app store. Los usuarios reciben la actualización en segundo plano mientras los cambios nativos siguen el flujo de revisión habitual.

Lo último de nuestro blog

Capgo te da la información clave que necesitas para crear una app móvil realmente profesional.