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.
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.
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:
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.
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.
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 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:
0000_initial.sql)migrations.js file that bundles them for Expometa/ for tracking migration historyAdd 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.
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.
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.
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.
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.
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.
.sql to sourceExts is non-negotiable. Without it, migrations won’t importrelations export enables Drizzle’s relational query API. Without it, you’re back to manual joinsuseMigrations. This prevents timing bugsnoteKeys pattern prevents cache bugsexpo customize – If you skip this and try to add SQL support to Metro config that doesn’t exist, nothing happensdrizzle-kit generate leaves your DB out of sync. Add it to your workflowwith – If you forget to export notesRelations, the with: { folder: true } query throws a runtime errorDrizzle 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:
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'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 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.

React, Angular, and Vue still lead frontend development, but 2025 performance is shaped by signals, compilers, and hydration. Here’s how they compare.

Explore five bizarre browser APIs that open up opportunities for delightful interfaces, unexpected interactions, and thoughtful accessibility enhancements.

Compare the top AI development tools and models of December 2025. View updated rankings, feature breakdowns, and find the best fit for you.

Fixing AI code, over-engineering JavaScript, and more: discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the December 10th issue.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now