- Published on
Offline Storage in Expo with SQLite & Drizzle ORM (2025 Guide)
- Authors
- Name
- Ahmed Farid
- @
TIP
Stop wrestling with raw SQL strings—Drizzle gives you end-to-end TypeScript types and runs in React Native.
In this tutorial you will:
- Set up
expo-sqlite
+ Drizzle ORM in an Expo SDK 50 app. - Define schema & migrations with full TypeScript safety.
- Implement CRUD operations with composable hooks.
- Handle database version upgrades.
- Sync offline changes with a remote API.
Table of Contents
- Table of Contents
- 1. Prerequisites & Terminology
- 2. Install Dependencies
- 3. Create SQLite Connection
- 4. Define Schema with Drizzle DSL
- 5. Run Migrations on Startup
- 6. CRUD Operations with Hooks
- 7. Handling Migrations & Versioning
- 8. Offline → Online Sync Strategy
- 9. Performance Tips
- 10. Testing & Debugging
- 11. Further Reading & Resources
- 12. Conclusion
1. Prerequisites & Terminology
- Expo SDK 50 (managed workflow).
- Node.js 18+.
- Familiarity with React Navigation & Context API.
Term | Meaning |
---|---|
expo-sqlite | Expo wrapper around react-native-sqlite-storage |
Drizzle ORM | Lightweight, SQL-like TypeScript ORM supporting SQLite |
Migration | Script that upgrades database schema between versions |
2. Install Dependencies
yarn add expo-sqlite drizzle-orm drizzle-orm-sqlite
NOTE
Drizzle 2.5+ ships a Bun-powered CLI; we’ll use it only at dev-time to generate types.
3. Create SQLite Connection
src/db/connection.ts
:
import * as SQLite from 'expo-sqlite'
import { drizzle } from 'drizzle-orm-sqlite'
const expoDb = SQLite.openDatabase('todos.db')
export const db = drizzle(expoDb)
openDatabase
auto-creates the file in app sandbox.
4. Define Schema with Drizzle DSL
src/db/schema.ts
:
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm-sqlite'
export const todos = sqliteTable('todos', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
completed: integer('completed', { mode: 'boolean' }).default(false),
updatedAt: integer('updated_at').notNull(), // epoch ms
})
Run Drizzle CLI to generate types (optional):
npx drizzle-kit generate:sqlite --schema=src/db/schema.ts
5. Run Migrations on Startup
Create a simple migration helper (until Drizzle CLI gains RN runtime support):
src/db/migrate.ts
:
import { db } from './connection'
export async function migrate() {
// create table if not exists
await db.run(`CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
updated_at INTEGER NOT NULL
);`)
}
Call in App.tsx
before rendering UI:
const [ready, setReady] = useState(false);
useEffect(() => { migrate().then(() => setReady(true)); }, []);
if (!ready) return <SplashScreen />;
6. CRUD Operations with Hooks
src/hooks/useTodos.ts
:
import { db } from '@/db/connection'
import { todos } from '@/db/schema'
import { eq } from 'drizzle-orm-sqlite'
export function useTodos() {
const [list, setList] = useState<any[]>([])
const refresh = () => {
const rows = db.select().from(todos).all()
setList(rows)
}
const add = (title: string) => {
db.insert(todos).values({ title, updatedAt: Date.now() }).run()
refresh()
}
const toggle = (id: number, completed: boolean) => {
db.update(todos).set({ completed, updatedAt: Date.now() }).where(eq(todos.id, id)).run()
refresh()
}
useEffect(refresh, [])
return { list, add, toggle }
}
Drizzle generates SQL under the hood but stays fully type-safe.
7. Handling Migrations & Versioning
Increase schema safely:
await db.run('ALTER TABLE todos ADD COLUMN priority INTEGER DEFAULT 0')
Track a PRAGMA user_version
and increment with each release.
8. Offline → Online Sync Strategy
- Add
synced
boolean &remoteId
columns. - Queue dirty rows (
synced=false
). - On connectivity regain (
NetInfo
), POST batch to API, marksynced=true
. - Use
updatedAt
timestamps for conflict resolution (last-write-wins).
9. Performance Tips
✅ Wrap multiple writes inside a single transaction db.transaction(() => { … })
.
✅ Use indices for filter columns db.run('CREATE INDEX IF NOT EXISTS idx_updated ON todos(updated_at)');
.
✅ Compact DB occasionally: db.run('VACUUM');
.
10. Testing & Debugging
- Enable SQL logging
SQLite.enablePromise(true)
and inspect with Flipper. - Detox e2e offline scenario: disable network with
device.setURLBlacklist(['.*']);
.
11. Further Reading & Resources
- Drizzle ORM docs: SQLite adapter.
- Expo SQLite guide.
- WatermelonDB or RxDB for reactive alternative.
12. Conclusion
With expo-sqlite and Drizzle ORM you get native performance, full TypeScript safety, and easy migrations—perfect for building robust offline-first experiences in Expo. ⚡📱