{ }
Published on

Offline Storage in Expo with SQLite & Drizzle ORM (2025 Guide)

Authors
  • avatar
    Name
    Ahmed Farid
    Twitter
    @

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:

  1. Set up expo-sqlite + Drizzle ORM in an Expo SDK 50 app.
  2. Define schema & migrations with full TypeScript safety.
  3. Implement CRUD operations with composable hooks.
  4. Handle database version upgrades.
  5. Sync offline changes with a remote API.

Table of Contents

1. Prerequisites & Terminology

  • Expo SDK 50 (managed workflow).
  • Node.js 18+.
  • Familiarity with React Navigation & Context API.
TermMeaning
expo-sqliteExpo wrapper around react-native-sqlite-storage
Drizzle ORMLightweight, SQL-like TypeScript ORM supporting SQLite
MigrationScript 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

  1. Add synced boolean & remoteId columns.
  2. Queue dirty rows (synced=false).
  3. On connectivity regain (NetInfo), POST batch to API, mark synced=true.
  4. 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. ⚡📱