Zum Hauptinhalt springen

So nutzt du SQLite in einer Capacitor App (Guide 2026)

Erfahre, wie du SQLite in einer Capacitor App nutzt: Plugin auswählen, Datenbank öffnen, Migrationen, Transaktionen, Verschlüsselung und Web-Support – mit funktionierenden code.

Capacitor Logo für eine Anleitung zur Nutzung von SQLite in einer Capacitor App

Um SQLite in einer Capacitor App zu nutzen, installiere ein natives SQLite-Plugin wie @capgo/capacitor-fast-sql, öffne beim Start einmal eine Datenbank, erstelle dein Schema mit versionierten Migrationen und lies und schreibe mit parametrisiertem SQL. Dieser Guide zeigt das komplette Setup auf Capacitor 8 für iOS, Android und Web – inklusive Transaktionen, Verschlüsselung, Dateispeicherorten und den häufigsten Fehlern.

Wenn du nur ein paar Einstellungen brauchst, ist SQLite Overkill. Wenn du Listen, Datensätze oder alles andere speichern willst, das du filtern, sortieren oder synchronisieren möchtest, ist es das richtige Tool.

Wann SQLite die richtige Speicherwahl ist

Capacitor Apps haben mehrere Orte, um Daten abzulegen. Sie lösen unterschiedliche Probleme:

Speicher Ideal für Limits
localStorage / IndexedDB Temporärer Cache Lebt in der WebView, das OS kann es verwerfen, keine echten Queries in localStorage
@capacitor/preferences Ein paar kleine Einstellungen (Theme, Sprache) Nur String-Werte, nicht für große Datenmengen gedacht, nicht verschlüsselt
Keychain-/Keystore-Speicher Tokens, Verschlüsselungsschlüssel Nur kleine Secrets
Dateien (@capacitor/filesystem) Bilder, PDFs, Exporte Keine Abfragen
SQLite Datensätze, Offline-Daten, Sync-Queues, Suche Du verwaltest Schema und Migrationen

Nutze SQLite, wenn du: Offline-First-Daten, mehr als ein paar hundert Datensätze, Filter und Sortierung, Tabellenbeziehungen, atomare Multi-Step-Writes oder Verschlüsselung für strukturierte Daten benötigst.

Die Wahl des richtigen Capacitor SQLite-Plugins im Jahr 2026

Es gibt drei realistische Optionen für natives SQLite auf Capacitor 8:

@capgo/capacitor-fast-sql @capacitor-community/sqlite @capgo/capacitor-data-storage-sqlite
Zweck Allgemeine SQL-Datenbank Allgemeine SQL-Datenbank String Key-Value Store auf SQLite
Plattformen iOS, Android, Web iOS, Android, Web, Electron iOS, Android, Web, Electron
Web-Engine Offizielles SQLite Wasm + OPFS jeep-sqlite (sql.js + IndexedDB) IndexedDB
Datentransport Lokaler HTTP-Channel, umgeht die Bridge Capacitor Bridge Capacitor Bridge
Verschlüsselung Optionales SQLCipher (iOS, Android) SQLCipher immer im Bundle enthalten SQLCipher, Passphrase in der Config
Zeilenformat Objekte mit Spalten-Keys Objekte mit Spalten-Keys Strings
ORM-Unterstützung Custom Driver (Kysely, Drizzle) TypeORM Built-in Driver Keine

Dieser Guide nutzt Fast SQL. Es wird von Capgo gepflegt, verfolgt Capacitor Major-Versionen (Plugin v8 für Capacitor 8), hält SQLCipher optional und vermeidet die Serialisierung großer Result-Sets über die Bridge. Falls du bereits das Community-Plugin nutzt, lies unseren Vergleichs- und Migrationsguide zuerst.

Fast SQL installieren

bun add @capgo/capacitor-fast-sql
bunx cap sync

Fast SQL startet einen kleinen HTTP-Server auf localhost und sendet SQL per Bearer-Token pro Verbindung dorthin. Beide Plattformen blockieren standardmäßig unverschlüsseltes HTTP; erlauben Sie es daher nur für Loopback.

iOS: lokales Netzwerk erlauben

Fügen Sie dies zu ios/App/App/Info.plist:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>

NSAllowsLocalNetworking deckt nur Loopback- und lokale Adressen ab. Die App Transport Security wird dadurch für Ihre echten API Aufrufe nicht gelockert.

Android: Cleartext für localhost erlauben

Erstellen 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>

Referenzieren Sie es in android/app/src/main/AndroidManifest.xml:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>

Falls Sie bereits eine Network Security Config nutzen (z. B. für SSL Pinning), fügen Sie den domain-config Block einfach dort hinzu, statt eine zweite Datei zu erstellen.

Datenbank einmalig öffnen

Erstellen Sie ein einzelnes Modul, das die Verbindung verwaltet. Die Datenbank ständig neu zu öffnen ist ineffizient, zudem benötigen Sie einen zentralen Ort für Migrationen.

// 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 und performancePresets sind ab Plugin-Version 8.0.49 verfügbar. performancePresets wird ebenfalls aktiviert foreign_keys, was SQLite standardmäßig nicht tut, damit ON DELETE CASCADE tatsächlich funktioniert.

Rufe getDb() wo immer du die Datenbank benötigst. Der erste Aufruf öffnet und migriert, spätere Aufrufe nutzen dasselbe Promise. Schlägt das Öffnen oder Migrieren fehl, versucht der nächste Aufruf es erneut.

Schema-Migrationen mit PRAGMA user_version

Fast SQL bietet dir rohes SQL statt eines Migration-Frameworks. Das ist völlig okay, da SQLite bereits ein Version-Feld im File-Header besitzt: PRAGMA user_version. Behalte ein geordnetes Array an Schritten und wende nur das an, was noch fehlt.

// 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}`);
    });
  }
}

Regeln, um die Sicherheit zu gewährleisten:

  • Ein SQL-Statement pro execute() aufrufen. Beide nativen Implementierungen bereiten nur das erste Statement eines Strings vor, daher "CREATE ...; CREATE ..." wird einer davon stillschweigend ausgeführt.
  • Jede Version läuft in einer Transaktion; ein Absturz auf halbem Weg lässt die Datenbank auf dem vorherigen Stand, und der Schritt wird beim nächsten Start wiederholt.
  • Bevorzuge additive Änderungen (neue Tabellen, neue nullable Spalten). SQLite unterstützt dies ALTER TABLE ... DROP COLUMN und RENAME COLUMN in neueren Versionen, aber das System-SQLite auf älteren Android-Releases kann veraltet sein. Der portabelste Weg, eine Tabelle umzustrukturieren, ist: neue Tabelle erstellen, kopieren, alte löschen, umbenennen.

Migrationen und Live Updates

Wenn du JavaScript-Änderungen mit Capgo Live Updatesauslieferst, wird deine code Migration mit dem Bundle mitgeliefert. Das ist praktisch, da ein Schema-Change kein Store-Release erfordert. Es bedeutet auch, dass ein Rollback ein älteres Bundle über ein neueres Schema legen kann. Halte Migrationen abwärtskompatibel (Spalten hinzufügen, aber nicht umbenennen oder löschen im selben Release, in dem sie nicht mehr genutzt werden), damit ältere Bundles weiterhin funktionieren.

Daten lesen und schreiben

SQLConnection bietet dir vier Methoden, die du ständig nutzen wirst:

Methode Rückgabewert Verwenden für
query(sql, params) SQLRow[] SELECT- und PRAGMA-Reads
run(sql, params) { rowsAffected, insertId } INSERT, UPDATE, DELETE
execute(sql, params) { rows, rowsAffected, insertId } Alles, vollständiges Ergebnis
executeBatch(ops) SQLResult[] Mehrere Statements in einem Roundtrip

Binde Werte immer mit ? Placeholdern. Erstelle SQL niemals per String-Konkatenation aus User-Input.

// 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;
}

Rows werden als Plain Objects mit Spaltennamen als Keys zurückgegeben, sodass kein Mapping-Schritt nötig ist. SQLite hat keinen Boolean-Typ, also speichere 1 und 0 und konvertiere sie an der Grenze deiner Data Layer, da setPinned tut.

Transaktionen und Batch-Writes

Umschließe jeden mehrstufigen Schreibvorgang mit einer Transaction. Das hält die Daten konsistent und ist viel schneller, da SQLite beim Commit nur einmal statt bei jedem einzelnen Statement auf die Disk synchronisiert.

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() committet, sobald der Callback aufgelöst wird, und führt einen Rollback aus, falls ein Fehler geworfen wird. Für manuelle Kontrolle gibt es auch beginTransaction(), commit() und rollback() auf der Verbindung.

Alle Aufrufe nutzen pro Datenbank eine einzige native Verbindung. Solange eine Transaktion offen ist, werden andere code, die auf dieselbe Datenbank schreiben, darin ausgeführt. Halte Transaktionen kurz und warte darin nicht auf Netzwerk-Requests.

Binäre Daten speichern

Übergib einen Uint8Array und du erhältst einen Uint8Array zurück. Das Plugin kodiert es für den Transport als Base64 und speichert einen echten 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;

Speichere große Medien (Fotos, Videos) als Dateien und lege in SQLite nur den Pfad ab. BLOBs eignen sich gut für Thumbnails, kleine Anhänge und Embeddings.

Datenbank verschlüsseln

Fast SQL unterstützt SQLCipher auf iOS und Android. Es ist Opt-in, sodass Apps, die es nicht brauchen, es auch nicht mitliefern:

const db = await FastSQL.connect({
  database: 'vault',
  encrypted: true,
  encryptionKey: keyFromSecureStorage,
});

Unter Android fügst du net.zetetic:sqlcipher-android zu deiner App build.gradlehinzu. Unter iOS stellst du den Pod auf den CapgoCapacitorFastSql/SQLCipher Das Generieren des Schlüssels, das Speichern im Keychain oder Keystore, das Verschlüsseln einer bestehenden Plaintext-Datenbank sowie das Rotieren von Keys werden Schritt für Schritt in So verschlüsselst du eine SQLite-Datenbank in Capacitorerklärt. Verschlüsselung ist im Web nicht verfügbar.

Web-Support

Im Web nutzt Fast SQL den offiziellen @sqlite.org/sqlite-wasm Build und speichert die Datei im Origin Private File System (OPFS). Es handelt sich um eine echte SQLite-Datei, nicht um eine Kopie im Speicher, die in IndexedDB geschrieben wird.

OPFS benötigt Cross-Origin-Isolation, daher müssen dein Dev-Server und dein Production-Host zwei Header senden:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Mit 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'],
  },
});

Fehlen die Header, loggt das Plugin eine Warnung und nutzt eine nicht-persistente Datenbank: Alles funktioniert, bis du die App neu lädst. CapgoCapacitorFastSql.configureWeb({ useOpfs, worker }) ermöglicht es dir, die Defaults vor dem ersten connect(), und auf Native hat es keine Auswirkung.

require-corp blockiert auch Cross-Origin-Bilder und Skripte ohne CORP- oder CORS-Header; teste also Third-Party-Embeds, wenn du es aktivierst.

Speicherort der Datenbankdatei

Plattform Ort Notizen
iOS Documents/<name>.db Standardmäßig in iCloud- und Geräte-Backups enthalten
Android files/<name>.db im internen Speicher der App Unterliegt den Android Auto Backup-Regeln
Web OPFS für den Page-Origin Wird gelöscht, wenn der Nutzer die Websitedaten löscht

Zwei praktische Konsequenzen: Erstens löscht das Deinstallieren der App die Datei auf beiden mobilen Plattformen. Zweitens können Backups die Datenbankdatei auf ein neues Gerät wiederherstellen. Das ist meistens gewünscht, aber wenn die Datenbank mit einem Schlüssel verschlüsselt ist, der nicht im Backup enthalten ist, kann die wiederhergestellte Datei nicht geöffnet werden. Der Encryption-Guide erklärt, wie man damit umgeht.

Performance-Checkliste

  • Einschalten walMode. Leser blockieren den Writer nicht mehr.
  • Batch-Writes in einer Transaktion mit executeBatch. Das ist der größte Performance-Gewinn für Imports und Sync.
  • Füge Indizes für Spalten hinzu, nach denen du filterst oder sortierst, und prüfe dies mit EXPLAIN QUERY PLAN.
  • Wähle nur die benötigten Spalten aus. Große TEXT und BLOB Spalten kosten Übertragungszeit, selbst über HTTP.
  • Nutze Pagination mit WHERE created_at < ? ORDER BY created_at DESC LIMIT 50 anstatt riesiger OFFSET Werte.
  • Nach dem Löschen vieler Daten solltest du VACUUM occasionally to shrink the file. It rewrites the whole database, so do it during idle time.

Fehlerbehebung

SQL execution failed auf iOS direkt nach dem Connect. Der lokale HTTP-Request wurde blockiert. Prüfe, ob NSAllowsLocalNetworking im richtigen Info.plist und die native App neu bauen; ein Live Reload reicht nicht aus.

CLEARTEXT communication to localhost not permitted auf Android. Die Network Security Config fehlt oder wird im Manifest nicht referenziert.

Nur das erste Statement wurde ausgeführt. Du hast mehrere Statements in einem String übergeben. Trenne sie oder nutze executeBatch.

no such table nach einem Update. Die Migration wurde nicht ausgeführt oder gegen einen anderen Datenbanknamen. Logge PRAGMA user_version beim Startup und vergleiche es mit deinem steps-Array.

Ein Filter auf einen Boolean- oder berechneten Wert schlägt unter Android immer fehl. Bei Read-Queries bindet die Android-Implementierung Parameter als Text. Vergleiche mit Spalten mit INTEGER-Affinität funktionieren weiterhin, da SQLite den Text konvertiert, aber Vergleiche mit Ausdrücken (z. B. json_extract(...) = ? oder count(*) > ?) vergleichen Text mit einer Zahl. Übergib 1/0 anstatt true/false, und verwende CAST(? AS INTEGER) beim Vergleich mit einem Ausdruck.

INSERT ... RETURNING gibt auf Android keine Zeilen zurück. In der aktuellen Plugin-Version gibt Android nur Zeilen für Statements zurück, die mit SELECT, PRAGMA oder EXPLAIN. Nutze insertId von run() und wählt die Zeile danach aus. Das Gleiche gilt für Queries, die mit beginnen, WITH: umschließe sie so, dass sie mit beginnen, SELECT, oder teste sie frühzeitig auf Android.

Daten verschwinden beim Reload im Browser. OPFS ist nicht verfügbar, was fast immer an fehlenden COOP/COEP-Headern auf dem Host liegt.

Encryption is not available in this build auf iOS. Du hast bestanden encrypted: true ohne auf die SQLCipher-Subspec zu wechseln.

Weiter geht's

Raw SQL reicht für kleine Apps. Sobald du mehr als ein paar Tabellen hast, hilft ein typed Layer:

Die Plugin-Referenz findest du im Fast SQL Docs und Plugin-Seite. Schnelles SQL erfordert einen Native Build bei Installation oder Konfigurationsänderung. Wenn du dafür keinen Mac warten möchtest, Capgo Build can produce signed iOS and Android binaries in the cloud. Since April 2026 App Store uploads must be built with Xcode 26 or later, so check your build machine before the next release.

Live-Updates für Capacitor-Apps

Wenn ein Bug im Web-Layer live ist, liefere den Fix über Capgo aus, statt Tage auf die App-Store-Freigabe zu warten. Nutzer bekommen das Update im Hintergrund, während native Änderungen im normalen Review-Prozess bleiben.

Neues aus unserem Blog

Capgo gibt dir die besten Insights, um eine wirklich professionelle Mobile App zu erstellen.