Build an offline-first React Native app
Copy page
Phones are offline more than laptops — tunnels, lifts, spotty cell, airplane mode. So a local-first app feels even better on mobile: every tap is instant because it hits a database on the phone, and sync happens in the background when there’s signal. This guide takes the same notes idea from the web guide onto React Native (Expo).
The good news: most of it is identical. There’s a real, on-device-verified version of the mobile
data layer at apps/op-sqlite-probe
— it runs on an actual iOS/Android device and prints OP-SQLITE-DEVICE: PASS.
What carries over unchanged
Section titled “What carries over unchanged”Your schema, your mutators, and your syncRules are the same code as the web app — they live in
@nizhal/kernel, which is platform-agnostic. The server is the same too. You do not rewrite your domain
for mobile; you reuse it. The only thing that changes is the client: where it stores data locally,
and how it talks to the network.
What changes: the storage engine
Section titled “What changes: the storage engine”On web the local store is wa-sqlite over OPFS. On React Native it’s
op-sqlite — a fast native SQLite. You open a database file
on the device and hand it to opSqlitePersistence:
import { open, IOS_DOCUMENT_PATH } from "@op-engineering/op-sqlite";import { createNizhalMutators, nizhalCollectionOptions, opSqlitePersistence,} from "@nizhal/db-collection";import { createCollection } from "@tanstack/db";import { notesMutators } from "./mutators"; // ← the SAME mutators as web
const db = open({ name: "notes.db", location: IOS_DOCUMENT_PATH });const store = await opSqlitePersistence({ database: db });
const notes = createCollection( nizhalCollectionOptions({ name: "notes", syncRule: "myNotes", echo, getKey: (row) => row.id, persistence: store.persistence, // ← the device's SQLite file }),);await notes.preload(); // load what's already on the phone, instantly
const { mutate, executor } = createNizhalMutators({ collections: { notes }, echo, actor: { userId, ownerId }, mutators: notesMutators, outboxStorage: store.outboxStorage, // ← the offline write queue, on disk});await executor.waitForInit();Two things to notice. store.persistence is what makes the notes survive the app being killed —
notes.preload() reads them straight off the device on next launch. And store.outboxStorage is the
durable queue: a note written in airplane mode is persisted to disk and replayed when the phone
reconnects, even if the user force-quits in between.
What changes: talking to the network
Section titled “What changes: talking to the network”React Native isn’t a browser — fetch and WebSocket behave differently. @nizhal/react-native provides
native-grade transports and the small polyfills RN needs:
import { installNizhalNativePolyfills, reactNativeOnlineDetector, nitroWebSocketSource,} from "@nizhal/react-native";
installNizhalNativePolyfills(); // crypto.randomUUID for Hermes, once at startup
const echo = createNizhalClient({ server: API_URL, auth: { getHeaders: () => ({ Authorization: `Bearer ${token}` }) }, bucketsForSyncRule: () => [ownerId], onlineDetector: reactNativeOnlineDetector(), // NetInfo: pull the moment signal returns});The mental model for realtime on mobile is the honest one: your UI is already reactive off the local
store, and when connectivity returns NetInfo triggers a pull, so you converge without anything fancy. If
you want instant server-pushed updates, add the native nitroWebSocketSource as the subscribeSource
— but it’s a latency optimisation, never a correctness requirement (the pull is authoritative). See
RFC-005 for the full story.
See it actually work — on a real device
Section titled “See it actually work — on a real device”Before building screens, prove the data layer on hardware. apps/op-sqlite-probe is an Expo app that, on
launch, writes through op-sqlite, closes the database, reopens it, and asserts the data survived — then
runs a cross-device sync against a host server:
cd apps/op-sqlite-probepnpm expo run:ios # or run:android — on a simulator or a real deviceOn screen you’ll see OP-SQLITE-DEVICE: PASS (op-sqlite persistence survives a restart) and
SYNC-DEVICE: PASS (a credit made on the web device shows up on the phone, and a payment made on the
phone converges back). The wiring is in
apps/op-sqlite-probe/src/ledger-probe.ts
(persistence) and
sync-probe.ts
(cross-device) — read those two files and you’ve seen the entire mobile data layer.
Install
Section titled “Install”pnpm add @nizhal/kernel @nizhal/db-collection @nizhal/react-nativepnpm add @op-engineering/op-sqlite @react-native-community/netinfo @tanstack/dbWhere to go next
Section titled “Where to go next”- Run the probe in
apps/op-sqlite-probe. - Start from the shared half: Build an offline-first web app (same schema/mutators/sync-rules).
- The realtime model in depth: React Native and Realtime.