# Magic Link on React Native (/wallets/react-native/magic-link)

> For the complete documentation index, see [llms.txt](/llms.txt)



The `useSendMagicLink` and `useVerifyMagicLink` hooks work identically to [the web](/wallets/auth/magic-link) 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 the dashboard-configured redirect URL with `?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 [#prerequisites]

* Complete the [Domain Association](/wallets/react-native/domain-association) setup — App Links and Universal Links only work on a domain that serves your verification files (`assetlinks.json` on Android, `apple-app-site-association` on iOS).
* Use an [Expo development build](https://docs.expo.dev/develop/development-builds/expo-go-to-dev-build/) — see the [quickstart](/wallets/react-native/quickstart#5-switch-to-an-expo-development-build).

## Set up the `/verify-email` link [#set-up-the-verify-email-link]

### Android: add an intent filter [#android-add-an-intent-filter]

With the domain association in place, `https://<your domain>/...` links can open the app directly ([Expo guide](https://docs.expo.dev/linking/android-app-links/)). Each path your app should catch needs its own filter in `app.json` — add one for `/verify-email`:

```jsonc
{
  "expo": {
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            {
              "scheme": "https",
              "host": "<your domain>",
              "pathPrefix": "/verify-email",
            },
          ],
          "category": ["BROWSABLE", "DEFAULT"],
        },
      ],
    },
  },
}
```

> After `app.json` changes, regenerate the native project: `npx expo prebuild --clean`, then rebuild. Intent filters land in `AndroidManifest.xml` and App Link verification happens at install time.

### iOS: the Universal Link is already claimed [#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](/wallets/react-native/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 --clean` and 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=developer` flag (see [Domain Association](/wallets/react-native/domain-association#2-ios-add-the-associated-domains-entitlement)) so the device fetches it straight from your origin.

### Allowlist the redirect URL [#allowlist-the-redirect-url]

On the [ZeroDev Dashboard](https://dashboard.zerodev.app/), add `https://<your domain>/verify-email` as an allowlisted **Magic Link Redirect URL**.

## Magic link flow [#magic-link-flow]

<div className="fd-steps">
  <div className="fd-step">
    ### Persist the send result [#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`:

    ```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;
    };
    ```
  </div>

  <div className="fd-step">
    ### Add the send panel with `useSendMagicLink` [#add-the-send-panel-with-usesendmagiclink]

    ```tsx
    import { useSendMagicLink } from "@zerodev/wallet-react";
    import { useState } from "react";
    import { Button, Text, TextInput } from "react-native";

    import { savePendingMagicLink } from "@/lib/magic-link-pending";

    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 }, { onSuccess: savePendingMagicLink })
            }
          />
        </>
      );
    }
    ```

    * The Magic Link Redirect URL configured in the dashboard must match the intent-filter host and route path exactly; the emailed link adds the `code` query parameter.
    * The mutation's response is exactly what the saver takes, so `onSuccess: savePendingMagicLink` passes it directly. TanStack Query awaits async `onSuccess` callbacks before flipping `isSuccess`, so "Check your email" can't appear before the bundle is persisted.
  </div>

  <div className="fd-step">
    ### Add the `verify-email` route [#add-the-verify-email-route]

    `app/verify-email.tsx` — must match the path in the link, or Expo Router shows "Unmatched Route":

    ```tsx
    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>;
    }
    ```

    * `useLocalSearchParams` reads `code` from the inbound link — search params belong to the route that received the deep link.
    * The `started` ref guards against double-firing: the code is single-use, so the effect must run exactly once.
    * `verifyMagicLink` auto-connects the wallet on success — the `status === "connected"` check is the success signal and redirects home.
  </div>
</div>
