Tauri SQLite Database Tutorial - Build a Contacts Manager App | React + Rust
Video: Tauri SQLite Database Tutorial - Build a Contacts Manager App | React + Rust by Taught by Celeste AI - AI Coding Coach
tauri-plugin-sqlplusbundledSQLite. CRUD against a real database, no manual schema management beyond migrations.
When your app needs to store data — contacts, notes, tasks, settings — JSON files quickly outgrow themselves. SQLite is the obvious next step. Tauri's SQL plugin makes it almost trivial: install the plugin, declare migrations, query from JS or Rust.
This lesson builds a contacts manager: add, list, edit, delete contacts, persisted to a SQLite database that survives restarts.
Install the plugin
cd src-tauri
cargo add tauri-plugin-sql --features sqlite
npm install @tauri-apps/plugin-sql
Register with migrations:
use tauri_plugin_sql::{Migration, MigrationKind};
let migrations = vec![
Migration {
version: 1,
description: "create contacts table",
sql: "CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);",
kind: MigrationKind::Up,
},
];
tauri::Builder::default()
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:contacts.db", migrations)
.build(),
)
// ...
The plugin runs migrations on first connect. The DB file lives in the app's data directory.
Capability:
"permissions": [
"core:default",
"sql:default",
"sql:allow-load",
"sql:allow-execute",
"sql:allow-select"
]
Connect to the database
Frontend:
import Database from "@tauri-apps/plugin-sql";
const db = await Database.load("sqlite:contacts.db");
Database.load opens the database (creating it if needed) and returns a handle. Call once at app startup; reuse the handle across the app.
Insert (Create)
await db.execute(
"INSERT INTO contacts (name, email, phone) VALUES ($1, $2, $3)",
[name, email, phone]
);
Parameterised queries: $1, $2, $3 are placeholders, with the values supplied as an array. Never use string concatenation to build SQL — that's how SQL injection happens. Always parameterise.
execute returns an object with lastInsertId and rowsAffected. Use lastInsertId to refresh the UI with the new row.
Select (Read)
interface Contact {
id: number;
name: string;
email: string;
phone: string | null;
created_at: string;
}
const contacts: Contact[] = await db.select(
"SELECT * FROM contacts ORDER BY name"
);
select returns rows as an array of objects, with column names as keys. The TypeScript interface keeps the schema and the consumer in sync.
For a single row:
const [contact] = await db.select<Contact[]>(
"SELECT * FROM contacts WHERE id = $1",
[contactId]
);
Update
await db.execute(
"UPDATE contacts SET name = $1, email = $2, phone = $3 WHERE id = $4",
[name, email, phone, id]
);
Same shape as insert. Parameterised values, returns rowsAffected.
Delete
await db.execute("DELETE FROM contacts WHERE id = $1", [id]);
Same.
The full CRUD wired up
function ContactsApp() {
const [contacts, setContacts] = useState<Contact[]>([]);
const [db, setDb] = useState<Database | null>(null);
useEffect(() => {
Database.load("sqlite:contacts.db").then(setDb);
}, []);
useEffect(() => {
if (db) refresh();
}, [db]);
async function refresh() {
const rows = await db!.select<Contact[]>(
"SELECT * FROM contacts ORDER BY name"
);
setContacts(rows);
}
async function addContact(name: string, email: string, phone: string) {
await db!.execute(
"INSERT INTO contacts (name, email, phone) VALUES ($1, $2, $3)",
[name, email, phone]
);
refresh();
}
async function deleteContact(id: number) {
await db!.execute("DELETE FROM contacts WHERE id = $1", [id]);
refresh();
}
// ... UI with form for adding, list with delete buttons
}
The pattern: every mutation calls refresh() afterwards. Simple and correct.
For high-frequency updates, optimise to update local state directly without a full refresh — but only when correctness is provable.
Migrations: adding a column later
When you need to extend the schema, add a new migration:
let migrations = vec![
Migration { version: 1, description: "create contacts", sql: "...", kind: MigrationKind::Up },
Migration {
version: 2,
description: "add notes column",
sql: "ALTER TABLE contacts ADD COLUMN notes TEXT;",
kind: MigrationKind::Up,
},
];
The plugin tracks which migrations have been applied. Existing users get the new ALTER on next launch; first-time users get both migrations.
Never edit a previously released migration — it would skip on existing databases. Always add a new one.
Search
const filtered = await db.select<Contact[]>(
"SELECT * FROM contacts WHERE name LIKE $1 OR email LIKE $1 ORDER BY name",
[`%${query}%`]
);
LIKE with % wildcards. For a faster search at scale, add indexes:
CREATE INDEX idx_contacts_name ON contacts(name);
Or use SQLite's FTS5 full-text search extension for proper search.
Backups
For user-facing apps that store important data, give users an "Export to backup file" feature:
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
async function exportContacts() {
const rows = await db.select<Contact[]>("SELECT * FROM contacts");
const path = await save({ defaultPath: "contacts.json" });
if (typeof path === "string") {
await writeTextFile(path, JSON.stringify(rows, null, 2));
}
}
Pair with import (read JSON, run inserts). Now your users can move data between machines.
Doing it from Rust
For complex queries or transactions involving multiple statements:
use sqlx::SqlitePool;
use tauri_plugin_sql::DbInstances;
#[tauri::command]
async fn add_contact_with_log(
state: tauri::State<'_, DbInstances>,
name: String,
email: String,
) -> Result<(), String> {
let pool: &SqlitePool = state.get_pool("sqlite:contacts.db").await
.ok_or("DB not loaded")?;
let mut tx = pool.begin().await.map_err(|e| e.to_string())?;
sqlx::query("INSERT INTO contacts (name, email) VALUES (?, ?)")
.bind(&name).bind(&email)
.execute(&mut *tx).await.map_err(|e| e.to_string())?;
sqlx::query("INSERT INTO audit_log (action, target) VALUES (?, ?)")
.bind("add_contact").bind(&email)
.execute(&mut *tx).await.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
Transactions: both inserts succeed together or both roll back. Essential for correctness when mutations span multiple tables.
Common mistakes
Building SQL with string concatenation. SQL injection. Always use placeholders.
Not running refresh() after mutations. UI stays stale; the user sees old data.
Storing booleans as BOOLEAN in SQLite. SQLite has no BOOLEAN — use INTEGER (0/1). The driver converts for you, but be aware.
No transactions for related mutations. Two inserts that should both succeed can leave inconsistent state if one fails. Wrap in BEGIN ... COMMIT.
Editing applied migrations. Existing user databases skip. Always add new migration entries.
What's next
Next lesson: another SQLite app — an article manager. Same plugin, different domain. Compares two designs side by side.
Recap
tauri-plugin-sql with the sqlite feature, declared migrations in the Rust builder, Database.load from the frontend. Parameterised queries with $1, $2, .... execute for mutations, select for reads. Refresh state after mutations. Use Rust commands for transactional or complex logic.
Next: article manager.