@lunora/react-native is the React Native / Expo entry to Lunora. It
re-exports the whole @lunora/react surface: the
hooks are react-dom-free and touch no browser-only global, so useQuery,
useMutation, useSubscription, useAuth, useConnectionStatus and the rest
run unchanged on native. On top of that it adds the two things a native app
needs that a browser gives you for free: an AsyncStorage-backed offline queue,
and credentialed requests (React Native has no cookie jar, so the session has to
be attached to every HTTP RPC call and the WebSocket upgrade explicitly).
CheckoutButton, CustomerPortalButton, and useCheckout are not part of that
surface: they render a DOM <button> and navigate via globalThis.location, so
they ship from the browser-only @lunora/react/payment subpath instead of the
@lunora/react root — there is nothing to strip out here. Drive purchases
through the platform's own purchase flow, or a WebView pointed at the web
checkout URL.
useVoiceAgent is re-exported, but its defaults are Web APIs (getUserMedia,
Web Audio, WebSocket) — on native, supply your own createMicrophone /
createSpeaker implementations.
See the React Native / Expo guide for the task-oriented walkthrough.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createLunoraClient, LunoraProvider, useQuery } from "@lunora/react-native";
import { api } from "@/lunora/_generated/api";
const client = createLunoraClient({
url: process.env.EXPO_PUBLIC_LUNORA_URL!,
storage: AsyncStorage,
});
const Root = () => (
<LunoraProvider client={client}>
<Chat />
</LunoraProvider>
);createLunoraClient(options)
A thin wrapper over new LunoraClient(options). Accepts everything on
LunoraClientOptions (url, wsUrl, authBasePath, persistenceVersion, …)
plus two React-Native conveniences:
storage: React NativeAsyncStorage(or anygetItem/setItem/removeItemstore). WirescreateAsyncStoragePersistenceso the offline mutation queue survives an app restart, andcreateAsyncStorageQueryCacheso query results repaint before the socket reconnects. The browser client auto-probes IndexedDB, which React Native lacks, so without this both stay in memory. Each is opted out of separately —persistence: falsedisables the queue and leaves the query cache writing tostorage; passqueryCache: falsefor that.getAuthHeaders:() => Record<string, string> | undefined. A generic escape hatch for a custom credential header (an API-gateway key, a proxy token) attached to every HTTP RPC request and the WebSocket upgrade. For better-auth sessions prefer a bearer token (below); aCookieheader here would be rejected by the runtime's CSRF guard on anOrigin-less native request.
An explicit persistence, queryCache, fetch, or WebSocket takes precedence
over the convenience derived from storage / getAuthHeaders.
Hooks & provider
Re-exported verbatim from @lunora/react: LunoraProvider,
useLunora, useQuery, useMutation, useSubscription, useAuth,
usePresence, useConnectionStatus, usePaginatedQuery, useInfiniteQuery,
useStream, useFlag/useFlags, useRateLimit, the Authenticated /
Unauthenticated / AuthLoading gates, and the framework-neutral error
discriminators. Mount them inside a <QueryClientProvider> exactly as on the web.
@lunora/react-native/auth
The better-auth Expo bridge. Auth is a bearer token: React Native has no
cookie jar, and a Cookie header would be rejected by the runtime's CSRF guard
on an Origin-less native request, so the session rides the Authorization
header (HTTP RPC) and the ?token= query param (the socket) instead.
expoBearerToken(authClient): reads the better-auth Expo session token (fromgetCookie()) for use withclient.setAuthToken(HTTP) andclient.setWsToken(socket); returnsnullwhen signed out.expoClient,setupExpoFocusManager,setupExpoOnlineManager: re-exported from@better-auth/expo/client.
import { createAuthClient } from "better-auth/react";
import { expoClient, expoBearerToken } from "@lunora/react-native/auth";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_LUNORA_URL!,
plugins: [expoClient({ scheme: "myapp", storage: SecureStore })],
});
export const client = createLunoraClient({
url: process.env.EXPO_PUBLIC_LUNORA_URL!,
storage: AsyncStorage,
});
// Sync the token into the client whenever the session changes, in an effect keyed
// on `authClient.useSession()`. `expoBearerToken` is async since better-auth
// 1.7.1, so guard the write with a cleanup flag — two session changes in quick
// succession leave two reads in flight, and the slower one would otherwise
// reinstate the previous session's token:
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await expoBearerToken(authClient);
if (cancelled) return;
client.setAuthToken(token);
client.setWsToken(token ?? undefined);
})();
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 (see
@lunora/auth).