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 the entire @lunora/react surface — the
hooks are react-dom-free and touch no browser-only global, so useQuery,
useMutation, useSubscription, useAuth, usePresence, useConnectionStatus
and the rest run unchanged on a phone — and 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: there is no cookie jar in React Native, so the session has to be attached to every HTTP RPC call and the WebSocket upgrade explicitly.
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 fastest start is the expo template — 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-storagenpm install @lunora/react-native @lunora/react @tanstack/react-query react
npx expo install @react-native-async-storage/async-storageyarn add @lunora/react-native @lunora/react @tanstack/react-query react
npx expo install @react-native-async-storage/async-storagebun 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.
Authentication (better-auth + Expo)
React Native has no cookie jar, so 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();
useEffect(() => {
const token = expoBearerToken(authClient);
client.setAuthToken(token); // HTTP `Authorization: Bearer …`
client.setWsToken(token ?? undefined); // WS `?token=…`
}, [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 would be
rejected once signed in. A bearer token sidesteps that — it carries no
Cookie, so it's exempt. 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.
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) and re-exports
expoClient, setupExpoFocusManager, and setupExpoOnlineManager from
@better-auth/expo/client.