# @lunora/react-native
**React Native / Expo integration for [Lunora](https://lunora.sh).**
> **Experimental** — this package is outside the Lunora 1.0 stability promise: its API may change in any release, without a major version bump.
---
The same live hooks you use on the web — `useQuery`, `useMutation`,
`useSubscription`, `useAuth`, `usePresence`, … — running on your phone, plus the
two seams a native app needs that a browser gives you for free: a durable offline
queue backed by `AsyncStorage`, and credentialed requests (there is no cookie jar
in React Native, so the session has to be attached explicitly).
This package **re-exports the entire `@lunora/react` surface**, so you import
your hooks and provider from here, and adds:
- `createLunoraClient(options)` — a `LunoraClient` factory tuned for React
Native.
- `@lunora/react-native/auth` — a one-import bridge to the
[better-auth Expo](https://www.better-auth.com/docs/integrations/expo) plugin.
## Install
```sh
pnpm add @lunora/react-native @lunora/react @tanstack/react-query react
# offline-queue persistence:
npx expo install @react-native-async-storage/async-storage
# only if you use auth:
npx expo install expo-secure-store expo-web-browser expo-linking expo-constants expo-network
pnpm add better-auth @better-auth/expo
```
## Quick start
```tsx
// 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, // persists offline writes across app restarts
});
```
```tsx
// 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 (
);
}
```
```tsx
// 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 m._id} renderItem={({ item }) => {item.text}} />;
}
```
`useQuery` opens a live WebSocket subscription — the list updates in place as
other clients write, and `send` is optimistic and offline-safe.
## 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 carries it
in the `?token=` query param. A bearer avoids the `Cookie` header the runtime's
CSRF guard rejects on an `Origin`-less native request (see [Why a bearer token](#why-a-bearer-token)).
```tsx
// auth.ts
import { createAuthClient } from "better-auth/react";
import { expoClient } 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 })],
});
```
```tsx
// 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:
```tsx
// App.tsx
import { useEffect } from "react";
import { expoBearerToken } from "@lunora/react-native/auth";
import { authClient } from "./auth";
import { client } from "./lunora";
function Root() {
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]);
// …render the app
}
```
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`):
```ts
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;
// },
// });
```
### Why a bearer token
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-based credential
would be **rejected** once signed in. A bearer token carries no `Cookie`, so it's
exempt — and it works identically on `react-native-web` (the browser lets you set
`Authorization`, and the token rides `?token=` on the socket).
### TanStack Query focus / online managers
React Native doesn't fire the browser's `focus` / `online` events, so Query
won't auto-refetch on app resume unless you point its managers at Expo's signals:
```ts
import { setupExpoFocusManager, setupExpoOnlineManager } from "@lunora/react-native/auth";
setupExpoFocusManager();
setupExpoOnlineManager();
```
## API
### `createLunoraClient(options)`
Everything on `LunoraClientOptions` (`url`, `wsUrl`, `authBasePath`,
`persistenceVersion`, …) plus:
| Option | Description |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `storage` | React Native `AsyncStorage` (or any `getItem`/`setItem`/`removeItem` store). Wires `createAsyncStoragePersistence` for the offline queue. |
| `getAuthHeaders` | `() => Record \| undefined`. Generic escape hatch for a **custom** credential header (API-gateway key, proxy token) on every HTTP RPC + the WebSocket upgrade. For better-auth sessions prefer a bearer token (`expoBearerToken` + `setAuthToken`/`setWsToken`) — a `Cookie` header here would trip the CSRF guard. |
An explicit `persistence`, `fetch`, or `WebSocket` always takes precedence over
the convenience derived from `storage` / `getAuthHeaders`.
### `@lunora/react-native/auth`
- `expoBearerToken(authClient)` — reads the better-auth Expo session token (from
`getCookie()`) for use with `client.setAuthToken` / `setWsToken`; `null` when
signed out.
- `expoClient`, `setupExpoFocusManager`, `setupExpoOnlineManager` — re-exported
from `@better-auth/expo/client`.
## Example app
A complete Expo chat app (auth + live messages + offline queue) lives at
[`examples/expo`](../../examples/expo) in this repo.
## License
FSL-1.1-Apache-2.0 — see [LICENSE.md](./LICENSE.md).