If you’ve worked with SQLite in React Native, you already know where things get annoying. Migrations need babysitting, type-safe queries take extra effort, and keeping your schema aligned with TypeScript can turn into a constant chore. It works, but it’s not pretty. Drizzle ORM shifts that balance in a big way.

I spent some time wiring Drizzle into Expo’s SQLite setup, and once you push past a couple of initial setup quirks, the experience is surprisingly smooth. To make it concrete, I put together a small notes app with folders. Nothing flashy, just enough t…
If you’ve worked with SQLite in React Native, you already know where things get annoying. Migrations need babysitting, type-safe queries take extra effort, and keeping your schema aligned with TypeScript can turn into a constant chore. It works, but it’s not pretty. Drizzle ORM shifts that balance in a big way.

I spent some time wiring Drizzle into Expo’s SQLite setup, and once you push past a couple of initial setup quirks, the experience is surprisingly smooth. To make it concrete, I put together a small notes app with folders. Nothing flashy, just enough to show how the pieces fit together.
In this post, we’ll walk through how to wire up Drizzle with Expo’s SQLite, generate and run migrations, and layer in TanStack Query for a clean, type-safe local data setup.
🚀 Sign up for The Replay newsletter
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it’s your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
Why Drizzle works well here
Drizzle isn’t trying to hide SQL from you. It gives you a type-safe layer on top of it. For local SQLite databases in mobile apps, this is exactly what you want:
- Type inference – Your schema definitions generate TypeScript types automatically. No manual interfaces
- Migrations built in – Drizzle Kit generates SQL migrations from your schema. It’s declarative, not imperative
- **Relational queries – **You can do SQL joins without writing SQL joins. Just define relations once
- **Zero runtime overhead – **It’s essentially a query builder with TypeScript sugar
For this demo, I’m also using TanStack Query to handle the data layer. It’s not required, but it makes cache invalidation and refetching trivial.
Project setup
Start with a fresh Expo app:
npx create-expo-app@latest
Install the dependencies:
bun add expo-sqlite drizzle-orm @tanstack/react-query
bun add -d drizzle-kit
Now here’s the critical part that most tutorials skip: Expo’s Metro bundler doesn’t recognize .sql files by default. Drizzle generates migrations as .sql files, and if Metro can’t import them, your app crashes.
Run this:
npx expo customize
When prompted, use the spacebar to select both metro.config.js and babel.config.js, then hit Enter. This generates the default configs in your project so you can modify them.
Open metro.config.js and add SQL support:
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// Critical: Add SQL file support for Drizzle migrations
config.resolver.sourceExts.push('sql');
module.exports = config;
Without this, you’ll get a syntax error when the app tries to import migration files. I learned this the hard way.
Schema definition: Multi-file structure
Drizzle lets you organize schemas as you see fit. I prefer separate files for each table rather than a single giant schema file.
For the demo app, I need two tables: notes and folders. Here’s the structure:
db/
schema/
folders.ts
notes.ts
index.ts
client.ts
db/schema/folders.ts:
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const folders = sqliteTable("folders", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
color: text("color").default("#6366f1"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export type Folder = typeof folders.$inferSelect;
export type NewFolder = typeof folders.$inferInsert;
The $inferSelect and $inferInsert types are what make this powerful. You never manually write type definitions for your database rows.
db/schema/notes.ts:
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
import { folders } from "./folders";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
folderId: integer("folder_id").references(() => folders.id, {
onDelete: "set null",
}),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const notesRelations = relations(notes, ({ one }) => ({
folder: one(folders, {
fields: [notes.folderId],
references: [folders.id],
}),
}));
export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;
The notesRelations export is important. It tells Drizzle how tables relate to each other, which enables relational queries later. You’ll see this in action when we query notes with their folders.
db/schema/index.ts:
export * from "./folders";
export * from "./notes";
Simple barrel export to keep imports clean.
Drizzle kit configuration
Drizzle Kit is the migration generator. Create drizzle.config.ts at the project root:
import type { Config } from "drizzle-kit";
export default {
schema: "./db/schema",
out: "./drizzle",
dialect: "sqlite",
driver: "expo",
} satisfies Config;
The driver: "expo" part is critical. This tells Drizzle to generate migrations in a format that Expo SQLite can consume.
Generate your first migration:
bunx drizzle-kit generate
This creates a drizzle/ folder with:
- SQL migration files (e.g.,
0000_initial.sql) - A
migrations.jsfile that bundles them for Expo - Metadata in
meta/for tracking migration history
Add a convenience script to package.json:
{
"scripts": {
"db:generate": "drizzle-kit generate"
}
}
Now, whenever you change your schema, run bun run db:generate to create a new migration.
Database client setup
Create the Drizzle client that wraps Expo’s SQLite:
db/client.ts:
import { drizzle } from "drizzle-orm/expo-sqlite";
import { openDatabaseSync } from "expo-sqlite";
import * as schema from "./schema";
const expoDb = openDatabaseSync("notes.db", { enableChangeListener: true });
export const db = drizzle(expoDb, { schema });
This is your database instance. Import it anywhere you need to run queries. The enableChangeListener option lets you listen for database changes if you need real-time updates later.
Running migrations at app start
Migrations need to run before your app renders. Drizzle provides a useMigrations hook for this.
I wrapped everything in a custom provider in the root layout:
app/_layout.tsx:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useMigrations } from "drizzle-orm/expo-sqlite/migrator";
import { db } from "@/db/client";
import migrations from "@/drizzle/migrations";
const queryClient = new QueryClient();
function DatabaseProvider({ children }: { children: React.ReactNode }) {
const { success, error } = useMigrations(db, migrations);
if (error) {
console.error("Migration error:", error);
return <LoadingScreen />;
}
if (!success) {
return <LoadingScreen />;
}
return <>{children}</>;
}
export default function RootLayout() {
return (
<DatabaseProvider>
<QueryClientProvider client={queryClient}>
{/* Your app screens */}
</QueryClientProvider>
</DatabaseProvider>
);
}
The useMigrations hook runs synchronously. Your app won’t render until the database is ready. This prevents race conditions where components try to query before tables exist.
Query hooks with TanStack Query
I could query Drizzle directly in components, but TanStack Query adds caching, refetching, and invalidation. For this integration, I created custom hooks.
hooks/use-notes.ts:
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { eq, desc } from "drizzle-orm";
import { db } from "@/db/client";
import { notes, type NewNote } from "@/db/schema";
export const noteKeys = {
all: ["notes"] as const,
lists: () => [...noteKeys.all, "list"] as const,
detail: (id: number) => [...noteKeys.all, "detail", id] as const,
};
export function useNotes() {
return useQuery({
queryKey: noteKeys.lists(),
queryFn: async () => {
return db.query.notes.findMany({
orderBy: [desc(notes.updatedAt)],
with: {
folder: true,
},
});
},
});
}
export function useCreateNote() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: NewNote) => {
const result = await db.insert(notes).values(data).returning();
return result[0];
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: noteKeys.lists() });
},
});
}
The with: { folder: true } syntax is Drizzle’s relational query API in action. It figures out the join automatically using the notesRelations definition, so there’s no hand-written SQL involved. Everything is fully type-inferred end to end.
When a note is created, the mutation invalidates the list query and triggers a refetch. TanStack Query takes care of the coordination, so the UI stays in sync without extra wiring.
Using it in components
Here’s how it looks in a component:
Excerpt from app/(tabs)/index.tsx:
export default function NotesScreen() {
const { data: notes, isLoading } = useNotes();
const createNote = useCreateNote();
const [newNoteTitle, setNewNoteTitle] = useState("");
const handleCreateNote = async () => {
if (!newNoteTitle.trim()) return;
const note = await createNote.mutateAsync({
title: newNoteTitle.trim(),
content: "",
});
setNewNoteTitle("");
router.push({ pathname: "/modal", params: { noteId: note.id } });
};
return (
<View>
<TextInput
value={newNoteTitle}
onChangeText={setNewNoteTitle}
onSubmitEditing={handleCreateNote}
/>
<FlatList
data={notes}
renderItem={({ item }) => <NoteCard note={item} />}
/>
</View>
);
}
Clean. Type-safe. The notes array comes with full IntelliSense, including the nested folder object.
Editing and updates
For the editor screen, I fetch a single note and update it:
Excerpt from app/modal.tsx:
export default function NoteModal() {
const { noteId } = useLocalSearchParams<{ noteId: string }>();
const { data: note } = useNote(Number(noteId));
const updateNote = useUpdateNote();
const handleSave = async () => {
await updateNote.mutateAsync({
id: Number(noteId),
data: { title, content, folderId: selectedFolderId },
});
router.back();
};
// ... UI code
}
When you save, the update mutation invalidates both the list and detail queries. Anywhere displaying this note gets fresh data automatically.
What makes this setup work
- Metro config matters – Adding
.sqltosourceExtsis non-negotiable. Without it, migrations won’t import - Schema-first approach – Define your schema once, get migrations and types for free. No handwritten migration files or type definitions
- Relations are explicit – The
relationsexport enables Drizzle’s relational query API. Without it, you’re back to manual joins - **Migration timing – **Run migrations before rendering with
useMigrations. This prevents timing bugs - Query key patterns – TanStack Query’s invalidation system depends on consistent query keys. The
noteKeyspattern prevents cache bugs
A few** gotchas I ran into**
- Forgetting
expo customize– If you skip this and try to add SQL support to Metro config that doesn’t exist, nothing happens - Schema changes without migrations – Changing the schema without running
drizzle-kit generateleaves your DB out of sync. Add it to your workflow - Relations are required for
with– If you forget to exportnotesRelations, thewith: { folder: true }query throws a runtime error
Why this stack feels right
Drizzle doesn’t try to hide the database. It makes SQLite easier to work with without abstracting away what’s actually happening. You still write or generate SQL migrations. You still understand your schema. What you get in return is type safety and a genuinely clean API.
Pair that with Expo’s zero-config SQLite and TanStack Query’s caching layer, and you end up with a local-first data stack that feels modern without feeling magical.
For the demo app, that translated to:
- No manual type definitions for database models
- Automatic cache invalidation on mutations
- Relational queries without writing SQL joins
- Migrations that just work
If you’re building an Expo app that needs local storage, this combo is worth a look. The initial setup has a few quirks(yes, Metro config), but once it’s running, it’s solid.
Check out the full code at github.com/nitishxyz/expo-drizzle-sqlite-demo to see how everything fits together.
LogRocket: Instantly identify and recreate issues in your React Native apps
LogRocket’s Galileo AI watches sessions for you and and surfaces the technical and usability issues holding back your React Native apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket’s product analytics features surface the reasons why users don’t complete a particular flow or don’t adopt a new feature.
Start proactively monitoring your React Native apps — try LogRocket for free.
LogRocket understands everything users do in your web and mobile apps.
LogRocket lets you replay user sessions, eliminating guesswork by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks, and with plugins to log additional context from Redux, Vuex, and @ngrx/store.
With Galileo AI, you can instantly identify and explain user struggles with automated monitoring of your entire product experience.
Modernize how you understand your web and mobile apps — start monitoring for free.