DEV Community

Cover image for How to Use SQLite in Capacitor Apps
Robin for Capawesome

Posted on

How to Use SQLite in Capacitor Apps

If you are building a Capacitor app that needs to store more than a few key-value pairs, you will hit the limits of the Preferences API pretty quickly. SQLite is the natural next step: a full relational database that runs on the device, works offline, and handles thousands of rows without breaking a sweat.

In this tutorial, you will learn how to set up a Capacitor SQLite plugin across all four platforms, run queries and transactions, apply schema migrations, use full-text search, encrypt your database, and avoid the most common pitfalls along the way.

Why SQLite for Capacitor Apps?

Capacitor gives you a few options for storing data, but they serve very different purposes:

Storage Best for Watch out for
Preferences API Small key-value pairs (settings, flags) No queries, no relations, not built for large data
IndexedDB (WebView) Web-only caching Can be evicted by the OS under storage pressure
SQLite Structured, relational, offline-first data Needs a plugin on native platforms

The decisive difference: SQLite stores data in a real database file on the device's filesystem. It is persistent, fast, queryable with SQL, and battle-tested on every platform. IndexedDB, in contrast, lives in the JavaScript runtime's storage, which the operating system may clean up at any time — not something you want to explain to a user who just lost their offline data.

If your app has lists, relations, sync queues, or anything you would naturally model as tables, SQLite is the right tool.

Choosing a Capacitor SQLite Plugin

There are two actively maintained options:

  • @capacitor-community/sqlite: The community plugin. Free, open source, and around for years with wide adoption. On the web platform, it stores data in IndexedDB via jeep-sqlite.
  • @capawesome-team/capacitor-sqlite: The plugin from the Capawesome team. It supports Android, iOS, Web, and Electron with built-in encryption (SQLCipher), transactions, versioned schema migrations, full-text search with FTS5, and first-class ORM support for Drizzle, Kysely, and TypeORM. On the web, it uses the official SQLite WASM build instead of IndexedDB, and on Electron it uses the native node:sqlite module. It is part of the sponsorware-funded Capawesome Insiders program.

Both are solid choices. The community plugin is the way to go if you need a free solution. The Capawesome plugin focuses on a smaller, simpler API, prepared statements to prevent SQL injection by design, official SQLite builds on every platform, and commercial support. The rest of this tutorial uses the Capawesome plugin, but the concepts — migrations, transactions, encryption, full-text search — apply to any Capacitor SQLite setup.

Installation

The plugin is distributed via the Capawesome npm registry, so configure it first:

npm config set @capawesome-team:registry https://npm.registry.capawesome.io
npm config set //npm.registry.capawesome.io/:_authToken 
Enter fullscreen mode Exit fullscreen mode

Then install the plugin and sync your native projects:

npm install @capawesome-team/capacitor-sqlite @sqlite.org/sqlite-wasm
npx cap sync
Enter fullscreen mode Exit fullscreen mode

The @sqlite.org/sqlite-wasm package is only needed if you want to support the web platform.

By the way, if you are using an AI coding agent like Claude Code or Cursor, you can install the plugin with the Capawesome agent skill instead and let the agent handle the setup:

npx skills add capawesome-team/skills --skill capacitor-plugins
Enter fullscreen mode Exit fullscreen mode

Platform Setup

Android

The plugin works out of the box on Android. Two optional features are worth knowing about:

Encryption: To use SQLCipher-based encryption, enable it in your variables.gradle:

ext {
  capawesomeCapacitorSqliteIncludeSqlcipher = true // Default: false
}
Enter fullscreen mode Exit fullscreen mode

Bundled SQLite: Android ships with the system SQLite version, which varies by OS version and can be years old. If you need a recent SQLite version (for example for the latest FTS5 improvements), you can opt in to a bundled build:

ext {
  capawesomeCapacitorSqliteIncludeRequery = true // Default: false
}
Enter fullscreen mode Exit fullscreen mode

This requires the JitPack repository in your root build.gradle:

repositories {
  google()
  mavenCentral()
  maven { url 'https://jitpack.io' }
}
Enter fullscreen mode Exit fullscreen mode

iOS

No additional setup is required on iOS. The plugin supports both CocoaPods and Swift Package Manager.

Web

On the web, the plugin runs SQLite as WebAssembly using the official @sqlite.org/sqlite-wasm build, with data persisted in the Origin Private File System. Two things are required:

  1. Serve the WASM assets. With Angular, add them to your angular.json; with Vite, exclude the package from dependency optimization:
// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    exclude: ['@sqlite.org/sqlite-wasm'],
  },
  server: {
    headers: {
      'Cross-Origin-Embedder-Policy': 'require-corp',
      'Cross-Origin-Opener-Policy': 'same-origin',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode
  1. Set the COOP/COEP headers. SQLite WASM uses SharedArrayBuffer for persistent storage, and browsers only enable it in a cross-origin-isolated context. Your server (dev and production) must send:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Enter fullscreen mode Exit fullscreen mode

If persistence does not work in the browser, these headers are the first thing to check.

Then initialize the WASM module once at app startup, before opening a database:

import { Capacitor } from '@capacitor/core';
import { Sqlite } from '@capawesome-team/capacitor-sqlite';

const initialize = async () => {
  if (Capacitor.getPlatform() === 'web') {
    await Sqlite.initialize({
      worker: new Worker('/assets/sqlite-wasm/sqlite3-worker1.mjs', { type: 'module' }),
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

Electron

On Electron, the plugin uses the native node:sqlite module — no WASM, no third-party binaries. Databases are stored in the app's userData directory by default:

  • Windows: %APPDATA%\YourAppName\
  • macOS: ~/Library/Application Support/YourAppName/
  • Linux: ~/.config/YourAppName/

You can also open databases in subfolders or from absolute paths.

Opening a Database with Schema Migrations

Schema migrations are one of the most tedious parts of working with SQLite — and one of the easiest to get wrong. The plugin solves this with versioned upgrade statements that you declare right when you open the database:

import { Sqlite } from '@capawesome-team/capacitor-sqlite';

const { databaseId } = await Sqlite.open({
  path: 'mydb.sqlite3',
  version: 2,
  upgradeStatements: [
    {
      version: 1,
      statements: [
        'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER)',
      ],
    },
    {
      version: 2,
      statements: ['ALTER TABLE users ADD COLUMN email TEXT'],
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

The plugin tracks the database version and applies exactly the migrations the device is missing. A fresh install runs both statements; an app updating from version 1 only runs the ALTER TABLE. No hand-rolled migration bookkeeping, no "did this user already get the new column?" bugs.

A few more options worth knowing:

// In-memory database — perfect for tests or temporary data
const { databaseId } = await Sqlite.open();

// Read-only mode — prevents accidental writes, e.g. for shipped reference data
const { databaseId } = await Sqlite.open({
  path: 'catalog.sqlite3',
  readOnly: true,
});
Enter fullscreen mode Exit fullscreen mode

Opening an existing database file also just works — useful if you ship a pre-populated database with your app or import one from a backup.

Running Queries

Writes go through execute, reads through query. Both use prepared statements with bound values, which protects you from SQL injection by design — never concatenate user input into SQL strings:

await Sqlite.execute({
  databaseId,
  statement: 'INSERT INTO users (name, age) VALUES (?, ?)',
  values: ['Alice', 30],
});

const result = await Sqlite.query({
  databaseId,
  statement: 'SELECT * FROM users WHERE age > ?',
  values: [25],
});
console.log(result.columns); // ['id', 'name', 'age', 'email']
console.log(result.rows);    // [[1, 'Alice', 30, null]]
Enter fullscreen mode Exit fullscreen mode

All SQLite data types are supported: NULL, INTEGER, REAL, TEXT, and BLOB. Note that query returns rows as arrays in column order, with the column names available separately in result.columns — a compact format that avoids repeating keys for every row.

One important detail: only one SQL statement can be executed per call. Statements joined with ; will not all run. If you need to run several statements, call execute once per statement — or use a transaction, which is the better tool anyway.

Transactions

When multiple writes belong together, wrap them in a transaction so they either all succeed or all roll back:

await Sqlite.beginTransaction({ databaseId });
try {
  await Sqlite.execute({
    databaseId,
    statement: 'INSERT INTO users (name, age) VALUES (?, ?)',
    values: ['Alice', 30],
  });
  await Sqlite.execute({
    databaseId,
    statement: 'INSERT INTO users (name, age) VALUES (?, ?)',
    values: ['Bob', 25],
  });
  await Sqlite.commitTransaction({ databaseId });
} catch (error) {
  await Sqlite.rollbackTransaction({ databaseId });
  throw error;
}
Enter fullscreen mode Exit fullscreen mode

Transactions are not just about atomicity — they are also a massive performance win. Inserting 1,000 rows one execute at a time forces SQLite to commit to disk 1,000 times. Wrapped in a single transaction, it commits once. If you are bulk-importing data (for example during an initial sync), always batch the writes in a transaction.

Full-Text Search with FTS5

SQLite ships with a powerful full-text search engine, FTS5, and the plugin supports it out of the box. Create a virtual table, index your content, and search with MATCH:

await Sqlite.execute({
  databaseId,
  statement: 'CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(title, body)',
});

await Sqlite.execute({
  databaseId,
  statement: 'INSERT INTO notes_fts (title, body) VALUES (?, ?)',
  values: ['Meeting notes', 'Discussed the Capacitor SQLite migration plan'],
});

const result = await Sqlite.query({
  databaseId,
  statement: 'SELECT title FROM notes_fts WHERE notes_fts MATCH ? ORDER BY rank',
  values: ['sqlite'],
});
Enter fullscreen mode Exit fullscreen mode

This gives you ranked, tokenized search across thousands of records in milliseconds — entirely offline. For most apps, this removes the need for a separate search library.

Key-Value Store Included

Sometimes you still need simple key-value storage alongside your relational data — and it feels wrong to pull in a second storage plugin just for that. The plugin ships with a SqliteKeyValueStore helper that stores key-value pairs in your SQLite database:

import { Sqlite, SqliteKeyValueStore } from '@capawesome-team/capacitor-sqlite';

const store = new SqliteKeyValueStore(Sqlite);

await store.set({
  key: 'settings',
  value: JSON.stringify({ theme: 'dark', notifications: true }),
});

const result = await store.get({ key: 'settings' });
if (result.value) {
  const settings = JSON.parse(result.value);
  console.log(settings.theme); // 'dark'
}

await store.remove({ key: 'settings' });
Enter fullscreen mode Exit fullscreen mode

Since it lives in the same database file, your key-value data benefits from the same encryption and is included when you back up or export the database.

Encrypting the Database

Mobile devices get lost and stolen, so encrypting data at rest matters — especially if your app stores personal or business data. The plugin supports 256-bit AES encryption via SQLCipher. You just pass an encryption key when opening the database:

const { databaseId } = await Sqlite.open({
  path: 'mydb.sqlite3',
  encryptionKey: 'secret',
});
Enter fullscreen mode Exit fullscreen mode

You can rotate the key later without recreating the database:

const { databaseId } = await Sqlite.open({
  encryptionKey: 'old-secret',
});
await Sqlite.changeEncryptionKey({
  databaseId,
  encryptionKey: 'new-secret',
});
Enter fullscreen mode Exit fullscreen mode

Two important rules:

  1. Never hardcode the key. Generate it per device and store it in secure storage — for example the Capacitor Secure Preferences plugin, which uses the Android Keystore and iOS Keychain — and load it at runtime.
  2. Remember the platform limits. Encryption is supported on Android and iOS. On Electron, database encryption is not available since node:sqlite does not support SQLCipher.

Using ORMs: Drizzle, Kysely, and TypeORM

Raw SQL is fine for small apps, but as your schema grows you may want type-safe, autocompleted queries. The plugin works with the most popular TypeScript ORMs and query builders, and there are dedicated step-by-step guides for each:

With Drizzle or Kysely, your queries are checked at compile time against your schema — a typo in a column name becomes a TypeScript error instead of a runtime crash on a user's device.

Error Handling

SQLite errors carry a result code that tells you exactly what went wrong. The plugin exposes it on the error object, so you can react to specific failures programmatically:

try {
  await Sqlite.open({ path: '/invalid/path/to.db' });
} catch (error) {
  // `error.data.sqliteCode` contains the SQLite result code
  // (e.g. `14` for `SQLITE_CANTOPEN`)
  console.error(error.message, error.data?.sqliteCode);
}
Enter fullscreen mode Exit fullscreen mode

This is more robust than parsing error message strings, which can change between versions and platforms.

Performance Tips

A few practices that make a noticeable difference in real apps:

  • Batch writes in transactions. As mentioned above, this is the single biggest performance lever for bulk inserts.
  • Create indexes for your query patterns. If you frequently filter by a column (WHERE user_id = ?), add an index: CREATE INDEX idx_todos_user_id ON todos (user_id). Do this in a migration.
  • Run vacuum occasionally. SQLite does not automatically shrink the database file after large deletes. await Sqlite.vacuum({ databaseId }) reclaims the space — a good candidate for a maintenance task on app startup.
  • Close databases you no longer need. await Sqlite.close({ databaseId }) frees the underlying resources.
  • Use in-memory databases in tests. Opening without a path gives you a throwaway database with zero filesystem overhead.

Platform Limitations to Know

Every cross-platform abstraction has edges. These are the ones to keep in mind:

  • Web: Requires the COOP/COEP headers described above for persistent storage; without them, SharedArrayBuffer is unavailable.
  • Electron: No database encryption, and Node.js 22.5.0 or later (Electron 33+) is required for the native node:sqlite module.
  • All platforms: One SQL statement per execute/query call.

The plugin documentation keeps an up-to-date list of limitations and troubleshooting tips.

SQLite, Secure Preferences, or Vault?

A question that comes up a lot: when should data go into SQLite versus an encrypted key-value store? A simple decision guide:

  • Need queries, relations, or large datasets? → SQLite. An offline-first app that syncs structured records, or anything you would model with a server-side database.
  • Need encrypted key-value storage the app can read freely in the background? → Secure Preferences. Typical examples: OAuth refresh tokens, server-issued API keys.
  • Need encrypted storage the user must actively unlock with biometrics or a passcode? → Vault. Think password manager entries or TOTP secrets behind an "app lock" screen.

These are complementary, not competing — a common setup is SQLite for the app data, with the SQLite encryption key itself stored in Secure Preferences.

Conclusion

SQLite turns a Capacitor app into a real offline-first application: structured data, fast queries, atomic transactions, full-text search, and encryption at rest — on Android, iOS, Web, and Electron alike. With versioned upgrade statements, even schema migrations become a one-time declaration instead of an ongoing chore.

To go deeper, check out the Capacitor SQLite plugin documentation for the full API reference, platform-specific configuration, and troubleshooting guides.

Have questions or feedback? Drop a comment below — we would love to hear how you are using SQLite in your Capacitor apps.

Top comments (0)