Magic Link on React Native
The useSendMagicLink and useVerifyMagicLink hooks work identically to the web on React Native, but the redirect needs platform setup.
A magic link needs a verified https callback — an App Link on Android, a Universal Link on iOS — so that tapping the link in the email opens the app. Email clients (e.g. Gmail) don't render custom-scheme links, so a plain https link on your verified domain is required.
The flow: send → the backend emails ${redirectURL}?code=<otp> → the user taps it → the app opens at /verify-email → the route pairs the code from the URL with the persisted otpId/otpEncryptionTargetBundle and verifies.
Prerequisites
- Complete the Domain Association setup — App Links and Universal Links only work on a domain that serves your verification files (
assetlinks.jsonon Android,apple-app-site-associationon iOS). - Use an Expo development build — see the quickstart.
Set up the /verify-email link
Android: add an intent filter
With the domain association in place, https://<your domain>/... links can open the app directly (Expo guide). Each path your app should catch needs its own filter in app.json — add one for /verify-email:
{
"expo": {
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "<your domain>",
"pathPrefix": "/verify-email",
},
],
"category": ["BROWSABLE", "DEFAULT"],
},
],
},
},
}After
app.jsonchanges, regenerate the native project:npx expo prebuild --clean, then rebuild. Intent filters land inAndroidManifest.xmland App Link verification happens at install time.
iOS: the Universal Link is already claimed
On iOS there is no per-path entry in app.json — the applinks:<your domain> entitlement plus the AASA's components rule ({ "/": "/verify-email*" }) from the Domain Association setup already claim the path. Keep in mind:
- If you change which paths the AASA claims, redeploy it; entitlement changes additionally need
npx expo prebuild --cleanand a rebuild. - iOS only triggers Universal Links from a tap in another app (e.g. Mail) — typing the URL into Safari's address bar opens the website, not the app.
- Apple's CDN caches the AASA; during development use the
?mode=developerflag (see Domain Association) so the device fetches it straight from your origin.
Allowlist the redirect URL
On the ZeroDev Dashboard, add https://<your domain>/verify-email as an allowlisted Magic Link Redirect URL.
Magic link flow
1. Persist the send result
The emailed link only carries code; verification also needs the otpId and otpEncryptionTargetBundle returned by the send step. Persist them in AsyncStorage so the flow survives backgrounding and cold start (the user may not return until after the OS killed the app).
lib/magic-link-pending.ts:
import AsyncStorage from "@react-native-async-storage/async-storage";
const KEY = "magic-link-pending";
type Pending = { otpId: string; otpEncryptionTargetBundle: string };
export const savePendingMagicLink = (p: Pending) =>
AsyncStorage.setItem(KEY, JSON.stringify(p));
export const loadPendingMagicLink = async (): Promise<Pending | null> => {
const raw = await AsyncStorage.getItem(KEY);
return raw ? JSON.parse(raw) : null;
};2. Add the send panel with useSendMagicLink
import { useSendMagicLink } from "@zerodev/wallet-react";
import { useState } from "react";
import { Button, Text, TextInput } from "react-native";
import { savePendingMagicLink } from "@/lib/magic-link-pending";
import { RP_ID } from "@/wagmi.config";
export function MagicLinkFlow() {
const [email, setEmail] = useState("");
const send = useSendMagicLink();
if (send.isSuccess) return <Text>Check your email.</Text>;
return (
<>
<TextInput
value={email}
onChangeText={setEmail}
autoCapitalize="none"
placeholder="you@example.com"
/>
<Button
title="Send magic link"
disabled={send.isPending || !email}
onPress={() =>
send.mutate(
{ email, redirectURL: `https://${RP_ID}/verify-email` },
{ onSuccess: savePendingMagicLink },
)
}
/>
</>
);
}redirectURLmust match the intent-filter host and the route path exactly; the backend emails${redirectURL}?code=<otp>.- The mutation's response is exactly what the saver takes, so
onSuccess: savePendingMagicLinkpasses it directly. TanStack Query awaits asynconSuccesscallbacks before flippingisSuccess, so "Check your email" can't appear before the bundle is persisted.
3. Add the verify-email route
app/verify-email.tsx — must match the path in the link, or Expo Router shows "Unmatched Route":
import { useVerifyMagicLink } from "@zerodev/wallet-react";
import { Redirect, useLocalSearchParams } from "expo-router";
import { useEffect, useRef } from "react";
import { Text } from "react-native";
import { useAccount } from "wagmi";
import { loadPendingMagicLink } from "@/lib/magic-link-pending";
export default function VerifyEmail() {
const { code } = useLocalSearchParams<{ code?: string }>();
const { status } = useAccount();
const verify = useVerifyMagicLink();
const started = useRef(false);
useEffect(() => {
if (started.current || !code) return;
started.current = true;
// Load the persisted `otpId` and `otpEncryptionTargetBundle`.
loadPendingMagicLink().then(
(pending) => pending && verify.mutate({ code, ...pending }),
);
}, [code]);
if (status === "connected") return <Redirect href="/" />;
return <Text>Verifying…</Text>;
}useLocalSearchParamsreadscodefrom the inbound link — search params belong to the route that received the deep link.- The
startedref guards against double-firing: the code is single-use, so the effect must run exactly once. verifyMagicLinkauto-connects the wallet on success — thestatus === "connected"check is the success signal and redirects home.