Last updated:
@lunora/react-native is the React Native / Expo entry to Lunora. It
re-exports @lunora/react's data and auth surface:
useQuery, useMutation, useSubscription, useAuth, usePresence,
useConnectionStatus, <LunoraProvider> and the auth gates are react-dom-free
and touch no browser-only global, so they run unchanged on a phone.
The payment kit is not part of that surface. CheckoutButton,
CustomerPortalButton and useCheckout render a DOM <button> and navigate
with globalThis.location, neither of which exists in React Native, so they
live at @lunora/react/payment rather than on the root barrel — a native app
never imports them by accident. Drive a checkout from a native screen instead:
call the action that returns the provider's { url } (with useMutation) and
open it with Expo's Linking or WebBrowser.
On top of that it adds the two things a native app needs that a browser gives you for free:
- a durable offline queue backed by
AsyncStorage(React Native has no IndexedDB, which the browser client auto-probes), and - credentialed requests: the session is a bearer, attached explicitly to every HTTP RPC call and the WebSocket upgrade.
Both are wrapped up in createLunoraClient, plus a one-import better-auth Expo
bridge at @lunora/react-native/auth.
A complete example (auth, a live message list, optimistic + offline-safe sends) lives at
examples/expo.
Scaffold a new app
The expo template scaffolds a full Expo app (iOS, Android, and web) wired to a
Lunora worker backend, with auth and the live-chat example already in place:
lunora init my-app -t expoThen follow the generated README.md (create a D1 database, set AUTH_SECRET,
lunora codegen, and expo start). To add Lunora to an existing Expo app
instead, wire it by hand as below.
Install
pnpm add @lunora/react-native @lunora/react @tanstack/react-query react
npx expo install @react-native-async-storage/async-storageFor auth also add better-auth @better-auth/expo and
npx expo install expo-secure-store expo-web-browser expo-linking expo-constants expo-network.
Create the client
createLunoraClient is a thin wrapper over new LunoraClient(options). Pass
storage to persist the offline queue across restarts.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createLunoraClient } from "@lunora/react-native";
export const client = createLunoraClient({
url: process.env.EXPO_PUBLIC_LUNORA_URL!, // e.g. https://my-app.workers.dev
storage: AsyncStorage,
});Provide it
Mount <LunoraProvider> (re-exported from this package) inside a TanStack Query
provider, exactly as on the web.
import { LunoraProvider } from "@lunora/react-native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { client } from "./lunora";
import { Chat } from "./Chat";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<LunoraProvider client={client}>
<Chat />
</LunoraProvider>
</QueryClientProvider>
);
}Live queries and mutations
The hooks are the React ones; only the host components change (View, Text,
FlatList instead of DOM elements).
import { useMutation, useQuery } from "@lunora/react-native";
import { FlatList, Text } from "react-native";
import { api } from "./lunora/_generated/api";
export function Chat() {
const messages = useQuery(api.messages.list, {});
const { mutate: send } = useMutation(api.messages.send);
return <FlatList data={messages ?? []} keyExtractor={(m) => m._id} renderItem={({ item }) => <Text>{item.text}</Text>} />;
}useQuery opens a live WebSocket subscription; send is optimistic and, thanks
to the AsyncStorage persistence, queued and retried when offline.
Local-first (shapes, custom mutators, TanStack DB)
Lunora's local-first tier — defineShape,
defineMutator, defineCollections, bindMutators, useLiveQuery — runs on
React Native unchanged. One platform prerequisite:
Install a WebCrypto polyfill first. Every optimistic write mints a
client-side id through @tanstack/db's safeRandomUUID, which needs
crypto.randomUUID or crypto.getRandomValues. Hermes ships neither.
Without the polyfill @lunora/db throws at setup naming this; before that guard
existed the first write failed as an unhandled promise rejection with
No secure random number generator available — no UI error, the row simply
never appeared.
npx expo install expo-crypto// Must be the FIRST import, before anything that reaches @lunora/db or @tanstack/db.
import "expo-crypto";
import { registerRootComponent } from "expo";
import App from "./App";
registerRootComponent(App);react-native-get-random-values works equally well if you are not on Expo.
Custom mutators bound with bindMutators are optimistic and rebased, not
offline-durable: they push straight through client.callMutator, so with the
server unreachable the write is rejected, the optimistic row rolls back, and
onWriteRejected fires. Only defineCollections drives the durable
@tanstack/offline-transactions outbox. Reach for defineCollections when a
write has to survive being made offline.
Authentication (better-auth + Expo)
The session is sent as a bearer token: the HTTP RPC carries it in the
Authorization header and the live socket in the ?token= query param (see
How auth reaches the live socket).
The better-auth Expo
plugin persists the session in SecureStore; expoBearerToken reads the token
out.
import { expoClient } from "@lunora/react-native/auth";
import { createAuthClient } from "better-auth/react";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_LUNORA_URL!,
plugins: [expoClient({ scheme: "myapp", storage: SecureStore })],
});import AsyncStorage from "@react-native-async-storage/async-storage";
import { createLunoraClient } from "@lunora/react-native";
export const client = createLunoraClient({
url: process.env.EXPO_PUBLIC_LUNORA_URL!,
storage: AsyncStorage,
});Bridge the session into the client whenever it changes: setAuthToken for HTTP,
setWsToken for the socket.
import { expoBearerToken } from "@lunora/react-native/auth";
import { useEffect } from "react";
import { authClient } from "./auth-client";
import { client } from "./lunora";
// inside a component that renders once the session is known:
const { data: session } = authClient.useSession();
// `expoBearerToken` is async since better-auth 1.7.1, and an async function is
// not a valid effect cleanup return — so kick off a promise instead. `cancelled`
// is load-bearing: two session changes in quick succession leave two reads in
// flight, and without it the slower one reinstates the previous session's token.
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await expoBearerToken(authClient);
if (cancelled) return;
client.setAuthToken(token); // HTTP `Authorization: Bearer …`
client.setWsToken(token ?? undefined); // WS `?token=…`
})();
return () => {
cancelled = true;
};
}, [session]);On the server, add better-auth's expo() and bearer() plugins, and fold
the socket's ?token= into an Authorization header in resolveIdentity:
import { bearer } from "@lunora/auth/plugins";
import { expo } from "@better-auth/expo";
// authOptions.plugins: [expo(), bearer(), /* … */]
// authOptions.trustedOrigins: ["myapp://"]
// createWorker({
// resolveIdentity: async (request) => {
// const headers = new Headers(request.headers);
// const wsToken = new URL(request.url).searchParams.get("token");
// if (wsToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${wsToken}`);
// const session = await auth.api.getSession({ headers });
// return session?.user?.id ? { userId: session.user.id } : null;
// },
// });scheme in app.json, the expoClient({scheme}) call, and the server's trustedOrigins must all match.How auth reaches the live socket
Lunora's runtime enables a CSRF Origin-check by default: it rejects any
state-changing HTTP request or WebSocket upgrade that carries a Cookie but no
trusted Origin. React Native sends no Origin, so a cookie credential is
rejected once signed in. A bearer token is the credential instead: the HTTP
RPC sends Authorization: Bearer <token>; the socket can't set headers from a
browser, so the token rides ?token=, and the worker's resolveIdentity folds it
back into an Authorization header for better-auth's bearer plugin. The same
wiring works unchanged on react-native-web.
Using a bearer does not on its own guarantee the absence of a Cookie
header. React Native has a real cookie jar — fetch is backed by the platform
HTTP stack (NSURLSession / OkHttp) and its shared cookie store — so the
Set-Cookie a better-auth sign-in returns is kept and re-attached to later
requests, and the guard then 403s every state-changing RPC with
FORBIDDEN_ORIGIN. It is intermittent (it depends on whether the jar holds the
cookie that launch), and security.csrf.trustedOrigins cannot fix it: the trust
list is only consulted for an Origin that was actually received, and a missing
one is rejected outright.
createLunoraClient closes this by sending credentials: "omit" on every
request. If you pass your own fetch, wrap it in withoutAmbientCookies (or set
credentials: "omit" yourself) — otherwise you inherit the bug.
API
createLunoraClient(options) accepts everything on LunoraClientOptions plus
storage (an AsyncStorage-shaped store, wired to the offline queue) and
getAuthHeaders (() => Record<string, string> | undefined, a generic
custom-header escape hatch; prefer a bearer token for better-auth sessions). An
explicit persistence, fetch, or WebSocket takes precedence.
@lunora/react-native/auth exports expoBearerToken(authClient) (async) and re-exports
expoClient, setupExpoFocusManager, and setupExpoOnlineManager from
@better-auth/expo/client.