14Docs
React Native
@bounded-sh/client ships a dedicated React Native entry (Metro resolves the react-native condition automatically), so the same client API runs on web and RN. You inject a few platform implementations once at startup, then init, get, set, subscribe, and auth work exactly as on web.
What Bounded builds—and what the mobile toolchain ships
Treat React Native as the client layer of the same full-stack Bounded app: build the screens and navigation, connect the shared SDK, deploy the policy and any Functions/runtime code, then exercise the complete flow on physical devices. Bounded does not provide a native Swift or Kotlin SDK, host the mobile binary, sign it, or publish it to the Apple App Store or Google Play. Expo/EAS or the native toolchain keeps that delivery responsibility.
One-time setup (before init)
React Native has no localStorage, no document, and no window.atob. Inject implementations once at startup. Two calls: setPlatform covers general browser-API gaps; ReactNativeSessionManager.configure registers the session-token store and is the canonical “this is React Native” signal.
npx expo install expo-web-browser expo-crypto react-native-mmkv \
react-native-get-random-values react-native-url-polyfill
npm install base-64import 'react-native-get-random-values' // crypto.getRandomValues (signing)
import 'react-native-url-polyfill/auto' // URL / URLSearchParams (realtime WS)
import { init, setPlatform, ReactNativeSessionManager } from '@bounded-sh/client'
import { createMMKV } from 'react-native-mmkv' // or an equivalent synchronous adapter
import { decode as atob, encode as btoa } from 'base-64'
const mmkv = createMMKV()
const storage = {
getItem: (k: string) => mmkv.getString(k) ?? null,
setItem: (k: string, v: string) => mmkv.set(k, v),
removeItem: (k: string) => mmkv.remove(k),
}
// (a) general browser-API shims (storage, base64, textEncode, …)
setPlatform({ storage, sessionStorage: storage, atob, btoa, hasDOM: false })
// (b) the session-token store. Configuring this is the canonical "this is RN"
// signal. The SDK then routes EVERY session read/write (login, restore,
// and the per-request auth header) through it.
ReactNativeSessionManager.configure({ storage, atob })
// …then init() as usual
await init({ appId: '<appId>' })Polyfills
react-native-get-random-values (signing) and react-native-url-polyfill/auto (the realtime WebSocket URL) are required; the on-chain write path also uses the buffer polyfill (a dependency already). The SDK adapter is synchronous; use MMKV or another compatible synchronous store, not an async storage API directly. MMKV requires a native development/production build rather than Expo Go. Tokens are stored as plaintext JSON in the selected adapter, so enable storage encryption in production.
Auth routes on React Native
Every supported route persists through the RN session store above and surfaces the same { id, address, email, isAnonymous } identity as web. Hosted login opens the system browser and returns through a registered HTTPS universal link. Full auth model: Auth.
| Route | On RN | How |
|---|---|---|
| Hosted login (default) | ✓ system browser | loginWithRedirect({ redirectUri }) for email, social, and optional text OTP |
| Guest / anonymous | ✗ standard RN | Current guest auth requires non-extractable WebCrypto Ed25519 plus IndexedDB; the RN session adapter does not provide that key store |
| Privy (social + wallet) | ✓ | authMethod: 'privy-expo' or loginWithPrivy(), see below |
| Phantom browser extension | ✗ web-only | throws a clear "web-only" error on RN |
Privy on React Native
@privy-io/expo is hook-based, so the host app renders <PrivyProvider> and bridges the hooks into a PrivyExpoProvider via setPrivyMethods(). The Privy Solana wallet signs the standard challenge, so the session is minted through the same wallet path as Phantom, with no Privy-specific backend verification. It is a co-equal route: loginWithPrivy() coexists with hosted Bounded Auth.
npx expo install expo-apple-authentication expo-application expo-clipboard \
expo-crypto expo-font expo-linking expo-secure-store expo-web-browser \
react-native-passkeys react-native-qrcode-styled@0.3.3 \
react-native-safe-area-context react-native-svg react-native-webview \
@expo-google-fonts/inter @privy-io/expo@0.70.1 \
@privy-io/expo-native-extensions@0.0.12
npm install viem@2.52.0 fast-text-encoding react-native-get-random-values \
@ethersproject/shims bufferimport 'fast-text-encoding'
import 'react-native-get-random-values'
import '@ethersproject/shims'
import { type ReactNode, useEffect } from 'react'
import { Buffer } from 'buffer'
import { useFonts, Inter_400Regular, Inter_500Medium, Inter_600SemiBold } from '@expo-google-fonts/inter'
import {
PrivyProvider,
useEmbeddedSolanaWallet,
useIdentityToken,
usePrivy,
} from '@privy-io/expo'
import { PrivyElements, useLogin } from '@privy-io/expo/ui'
import { PrivyExpoProvider, init, loginWithPrivy } from '@bounded-sh/client'
;(globalThis as typeof globalThis & { Buffer: typeof Buffer }).Buffer = Buffer
const PRIVY_APP_ID = '<privy-app-id>'
const PRIVY_CLIENT_ID = '<privy-native-client-id>'
const BOUNDED_APP_ID = '<bounded-app-id>'
const SOLANA_RPC_URL = '<solana-rpc-url>'
const provider = new PrivyExpoProvider(PRIVY_APP_ID, SOLANA_RPC_URL)
// Rendered inside PrivyProvider: adapt Privy's current hook/provider APIs to
// the stable callback shape consumed by @bounded-sh/client.
function PrivyBridge() {
const { isReady, user, logout, getAccessToken } = usePrivy()
const { login } = useLogin()
const walletState = useEmbeddedSolanaWallet()
const { getIdentityToken } = useIdentityToken()
useEffect(() => {
provider.setPrivyMethods({
isReady,
isAuthenticated: !!user,
user,
login: async () =>
(await login({ loginMethods: ['email', 'google', 'apple'] })).user,
logout,
getAccessToken,
getIdentityToken,
getWalletProvider: async () => {
if (!('wallets' in walletState) || !walletState.wallets) return null
const w = walletState.wallets[0]
if (!w) return null
const p = await w.getProvider()
return {
address: w.address,
signMessage: async (message) => {
const { signature } = await p.request({
method: 'signMessage',
params: { message: Buffer.from(message).toString('base64') },
})
return {
signature: Uint8Array.from(Buffer.from(signature, 'base64')),
}
},
signTransaction: async (transaction) => {
const { signedTransaction } = await p.request({
method: 'signTransaction',
params: { transaction },
})
return {
signedTransaction: Uint8Array.from(signedTransaction.serialize()),
}
},
signAndSendTransaction: async (transaction, connection) =>
p.request({
method: 'signAndSendTransaction',
params: { transaction, connection },
}),
}
},
})
}, [
getAccessToken,
getIdentityToken,
isReady,
login,
logout,
user,
walletState,
])
return null
}
export function AuthRoot({ children }: { children: ReactNode }) {
const [fontsLoaded] = useFonts({
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
})
if (!fontsLoaded) return null
return (
<PrivyProvider
appId={PRIVY_APP_ID}
clientId={PRIVY_CLIENT_ID}
config={{
embedded: { solana: { createOnLogin: 'all-users' } },
}}
>
<PrivyBridge />
<PrivyElements />
{children}
</PrivyProvider>
)
}
export async function initBoundedPrivy() {
await init({
appId: BOUNDED_APP_ID,
authMethod: 'privy-expo',
privyExpoProvider: provider,
rpcUrl: SOLANA_RPC_URL,
})
}
// Call from a user gesture after AuthRoot mounts and Privy is ready.
export async function signIn() {
return loginWithPrivy()
}In the Privy dashboard, register a native app client for the exact iOS bundle identifier and Android package used by the build, configure its redirect scheme, and use that client's ID as PRIVY_CLIENT_ID. The versions above are pinned to the adapter contract shown here. Before changing them, repeat login and Solana message/transaction signing on a physical-device build. This section documents the explicit Expo bridge; the published default web entry does not expose an automatic @privy-io/react-auth provider.
Metro and optional peers
The React Native entry excludes web Privy and react-dom, so hosted-auth apps do not need Metro stubs for those packages. Apps using hosted login install expo-web-browser and expo-crypto (or supply compatible randomness); Expo Privy apps install the peers listed above. Add custom Metro aliases only for dependencies your own shared code imports.
Realtime & notes
subscribe()and the live runtime use the globalWebSocketwith the auth token passed by query string (RN-correct). Prefer them over the web-oriented offline store. See Realtime & live.- Reads, writes, files, search, and functions are identical to web. See SDK reference.