React Native Quickstart
Get an Embedded Wallet running in Expo
ZeroDev Wallet runs on React Native. The same Wagmi-based API you use on the web works in native apps — the main difference is that on React Native the storages and stampers must be configured explicitly, since there are no platform defaults.
This guide walks you through setting up the SDK in an Expo app and signing in with email OTP. Once connected, sending transactions and signing messages is plain Wagmi.
Prerequisites
- Create a new project on the ZeroDev Dashboard.
- Enable Sepolia and Arbitrum Sepolia.
Set up the project
Create a new Expo app:
npx create-expo-app@latest <app_name>yarn create expo-app <app_name>pnpm create expo-app@latest <app_name>bun create expo-app@latest <app_name>Copy the project ID from the prerequisites into a .env file in the project root:
EXPO_PUBLIC_ZERODEV_PROJECT_ID=<your project id>Install the SDK:
npm i @zerodev/[email protected] @zerodev/[email protected]yarn add @zerodev/[email protected] @zerodev/[email protected]pnpm add @zerodev/[email protected] @zerodev/[email protected]bun add @zerodev/[email protected] @zerodev/[email protected]Install Wagmi (per the Wagmi getting started guide):
npm i wagmi [email protected] @tanstack/react-queryyarn add wagmi [email protected] @tanstack/react-querypnpm add wagmi [email protected] @tanstack/react-querybun add wagmi [email protected] @tanstack/react-queryZeroDev Wallet works with both Wagmi v2 and v3. Wagmi v3 still requires [email protected], so the pinned viem version above applies to either.
Install the polyfills (events is a shim for a Node module that React Native doesn't ship):
npx expo install react-native-get-random-values eventsyarn expo install react-native-get-random-values eventspnpm expo install react-native-get-random-values eventsbunx expo install react-native-get-random-values eventsAdd the polyfill at the very top of your root _layout.tsx:
import "react-native-get-random-values";
import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router";
import { useColorScheme } from "react-native";
import { AnimatedSplashOverlay } from "@/components/animated-icon";
import AppTabs from "@/components/app-tabs";
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<AnimatedSplashOverlay />
<AppTabs />
</ThemeProvider>
);
}Configure the SDK
Install the SDK's remaining peer deps — the storage adapter and the secure store backing the API-key stamper:
npx expo install @react-native-async-storage/async-storage expo-secure-storeyarn expo install @react-native-async-storage/async-storage expo-secure-storepnpm expo install @react-native-async-storage/async-storage expo-secure-storebunx expo install @react-native-async-storage/async-storage expo-secure-storeCreate wagmi.config.ts with the zeroDevWallet connector. This replaces the plain config from Wagmi's getting-started guide:
import { createSecureStoreStamper } from "@zerodev/wallet-core/react-native/stampers/secure-store";
import { asyncStorageAdapter } from "@zerodev/wallet-core/react-native/storage/async-storage";
import { zeroDevWallet } from "@zerodev/wallet-react";
import { createConfig, createStorage, http } from "wagmi";
import { arbitrumSepolia, sepolia } from "wagmi/chains";
const ZERODEV_PROJECT_ID = process.env.EXPO_PUBLIC_ZERODEV_PROJECT_ID ?? "";
// A domain — see the note below.
export const RP_ID = "example.com";
const chains = [sepolia, arbitrumSepolia] as const;
export const wagmiConfig = createConfig({
chains,
connectors: [
zeroDevWallet({
projectId: ZERODEV_PROJECT_ID,
chains,
rpId: RP_ID,
apiKeyStamper: createSecureStoreStamper(),
sessionStorage: asyncStorageAdapter,
persistStorage: asyncStorageAdapter,
}),
],
transports: {
[sepolia.id]: http(),
[arbitrumSepolia.id]: http(),
},
storage: createStorage({ storage: asyncStorageAdapter }),
multiInjectedProviderDiscovery: false,
});
declare module "wagmi" {
interface Register {
config: typeof wagmiConfig;
}
}About RP_ID
RP_ID is a domain, as defined by the WebAuthn standard. As long as you don't use passkeys or App Links for OAuth / magic-link redirects, it doesn't have to be a valid domain you own. If you do use those features, the domain association must be set up and served from this same domain. Also, if you specify an Access Control List of whitelisted Origins on the ZeroDev Dashboard, the same domain needs to be on the allowlist as https://${RP_ID}/.
See Configuration for what each option does and which ones are required on React Native.
Then wrap the app in the Wagmi and React Query providers in _layout.tsx:
import "react-native-get-random-values";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router";
import { useColorScheme } from "react-native";
import { WagmiProvider } from "wagmi";
import { AnimatedSplashOverlay } from "@/components/animated-icon";
import AppTabs from "@/components/app-tabs";
import { wagmiConfig } from "@/wagmi.config";
const queryClient = new QueryClient();
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<WagmiProvider config={wagmiConfig}> {}
<QueryClientProvider client={queryClient}> {}
<ThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<AnimatedSplashOverlay />
<AppTabs />
</ThemeProvider>
</QueryClientProvider> {}
</WagmiProvider>
);
}Add OTP email auth
Email OTP is a two-step flow: send a code, then verify it. Verification connects the wallet (Wagmi's useAccount().status flips to "connected").
import { useSendOTP, useVerifyOTP } from "@zerodev/wallet-react";
import { useState } from "react";
import { Button, Text, TextInput } from "react-native";
const input = { borderWidth: 1, padding: 8, borderRadius: 4 };
export function OtpEmailFlow() {
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [otp, setOtp] = useState<{
otpId: string;
otpEncryptionTargetBundle: string;
} | null>(null);
const sendOTP = useSendOTP();
const verifyOTP = useVerifyOTP();
// Step 1: email -> send a code
if (otp === null) {
return (
<>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="[email protected]"
autoCapitalize="none"
keyboardType="email-address"
style={input}
/>
<Button
title={sendOTP.isPending ? "Sending…" : "Send code"}
disabled={sendOTP.isPending || !email}
onPress={() => sendOTP.mutate({ email }, { onSuccess: setOtp })}
/>
{sendOTP.error ? (
<Text style={{ color: "red" }}>{sendOTP.error.message}</Text>
) : null}
</>
);
}
// Step 2: code -> verify and connect the wallet
return (
<>
<TextInput
value={code}
onChangeText={setCode}
placeholder="123456"
keyboardType="number-pad"
style={input}
/>
<Button
title={verifyOTP.isPending ? "Verifying…" : "Verify"}
disabled={verifyOTP.isPending || !code}
onPress={() => verifyOTP.mutate({ code, ...otp })}
/>
{verifyOTP.error ? (
<Text style={{ color: "red" }}>{verifyOTP.error.message}</Text>
) : null}
</>
);
}The useSendOTP and useVerifyOTP hooks work identically to the web — see Email OTP for the full flow.
Send a transaction
Sending a transaction is plain Wagmi (useSendTransaction, useBalance, useAccount, …) — no ZeroDev-specific code needed:
import { parseEther } from "viem";
import { Button, Text } from "react-native";
import { useAccount, useSendTransaction } from "wagmi";
export function SendTransaction() {
const { status } = useAccount();
const { sendTransaction, isPending, data: hash, error } = useSendTransaction();
if (status !== "connected") return null;
return (
<>
<Button
title={isPending ? "Sending…" : "Send 0.001 ETH"}
disabled={isPending}
onPress={() =>
sendTransaction({
to: "0xd2135CfB216b74109775236E36d4b433F1DF507B",
value: parseEther("0.001"),
})
}
/>
{hash ? <Text>Transaction hash: {hash}</Text> : null}
{error ? <Text style={{ color: "red" }}>{error.message}</Text> : null}
</>
);
}Run the app — you now have a fully working wallet app: sign in with the email OTP flow to connect the wallet, then send a transaction.
Switch to an Expo development build
The features in the next guides (OAuth deep links, WebView export, passkeys) rely on native modules that Expo Go doesn't ship, so switch to a development build:
npx expo install expo-dev-clientyarn expo install expo-dev-clientpnpm expo install expo-dev-clientbunx expo install expo-dev-clientBuild and run on Android (environment setup):
npx expo run:androidyarn expo run:androidpnpm expo run:androidbunx expo run:androidOr on iOS — requires a Mac with Xcode (environment setup):
npx expo run:iosyarn expo run:iospnpm expo run:iosbunx expo run:iosThe first run generates the native project (expo prebuild), builds it, installs the dev client on the emulator / Simulator, and starts the bundler. If app.json doesn't define android.package / ios.bundleIdentifier yet, the CLI prompts for one.
What runs where
Email OTP, Google OAuth, and wallet export work on both platforms with no further setup — on iOS they run in the Simulator with a free Apple account. Passkeys and verified https links (Magic Link) additionally require the domain association setup: free on Android, while the iOS half (the Associated Domains entitlement) needs a paid Apple Developer membership and is best tested on a physical device.
Next Steps
- Configuration — Stampers, storage adapters, and connector options
- Google OAuth — Social login with Expo WebBrowser
- Domain Association — Link your app to a domain for passkeys and App Links
- Magic Link — Sign in with a link sent by email
- Passkeys — Native WebAuthn
- Export Wallet — Reveal the seed phrase or private key via WebView
- React Native Web — Run the same app on the web with react-native-web