Skip to content
DocsframeworksDocumentation

React Native / Expo

Use Lunora in a React Native / Expo app — the same live hooks, plus an AsyncStorage-backed client and a better-auth Expo bridge.

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 expo

Then 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-storage

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

lunora.ts
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.

App.tsx
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).

Chat.tsx
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
index.ts (entry file)
// 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.

auth-client.ts
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 })],
});
lunora.ts
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.

App.tsx
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;
//   },
// });
The 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.