Skip to content

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.

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.

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.

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.

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:

Terminal window
cd apps/op-sqlite-probe
pnpm expo run:ios # or run:android — on a simulator or a real device

On 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.

Terminal window
pnpm add @nizhal/kernel @nizhal/db-collection @nizhal/react-native
pnpm add @op-engineering/op-sqlite @react-native-community/netinfo @tanstack/db