# React Native Web (/wallets/react-native/web)

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



The Expo app from the [quickstart](/wallets/react-native/quickstart) also runs on the web via [`react-native-web`](https://necolas.github.io/react-native-web/). The only work is swapping a few React-Native-only integrations for web variants, then adjusting routing so the auth routes that still matter on web can mount.

<Callout type="info" title="The web build auto-defaults everything">
  On web the SDK fills in every platform piece for you — `apiKeyStamper → IndexedDB`, `passkeyStamper → WebAuthn`, `sessionStorage → localStorage`, `rpId → window.location.hostname`. So the web connector is just `zeroDevWallet({ projectId, chains })`, and **passkeys work on web for free**. See [Connector Options](/wallets/connector-options).
</Callout>

## Prerequisites [#prerequisites]

The default Expo starter already ships everything web needs — confirm it's there:

* `react-dom` and `react-native-web` in `package.json`
* a `"web": "expo start --web"` script
* `app.json` → `"web": { "output": "static", "favicon": … }`

## What changes from quickstart [#what-changes-from-quickstart]

The quickstart already gives you the native OTP flow. To make the same Expo app work on web:

* add `.web` siblings for files that call React-Native-only wallet helpers
* keep the passkey UI shared, but swap the native OAuth and export implementations for web ones
* move tabs under a root `Stack` so `app/verify-email.tsx` can mount
* type-check `.web` files separately and allowlist your web origin on the Dashboard

<div className="fd-steps">
  <div className="fd-step">
    ## Add web variants of the native-only files [#add-web-variants-of-the-native-only-files]

    The `@zerodev/wallet-core/react-native/*` and `@zerodev/wallet-react/react-native/*` subpaths resolve to throw-on-use stubs on web. If a universal file calls one of those helpers during startup — for example, `wagmi.config.ts` creating native stampers at module load — the app fails before React renders. The fix is Metro's platform resolution: a `foo.web.tsx` file is used on web, `foo.tsx` everywhere else (the starter already does this for `animated-icon` and `app-tabs`). Add a `.web` sibling for each file that touches an RN-only module; the base file stays native.

    ### `wagmi.config.web.ts` [#wagmiconfigwebts]

    The web connector needs only `projectId` and `chains`:

    ```ts
    import { zeroDevWallet } from "@zerodev/wallet-react";
    import { createConfig, http } from "wagmi";
    import { arbitrumSepolia, sepolia } from "wagmi/chains";

    const ZERODEV_PROJECT_ID = process.env.EXPO_PUBLIC_ZERODEV_PROJECT_ID ?? "";
    export const RP_ID = "zdwalletdemo.vercel.app"; // kept for parity; unused on web

    const chains = [sepolia, arbitrumSepolia] as const;

    export const wagmiConfig = createConfig({
      chains,
      connectors: [zeroDevWallet({ projectId: ZERODEV_PROJECT_ID, chains })],
      transports: { [sepolia.id]: http(), [arbitrumSepolia.id]: http() },
      multiInjectedProviderDiscovery: false,
    });

    declare module "wagmi" {
      interface Register {
        config: typeof wagmiConfig;
      }
    }
    ```

    Leave `rpId` unset so WebAuthn matches the serving origin (localhost in dev, your https domain in prod), and omit Wagmi's `storage` (it defaults to `localStorage`).

    ### `magic-link-pending.web.ts` [#magic-link-pendingwebts]

    The same async API as the native AsyncStorage helper, backed by `localStorage`:

    ```ts
    const KEY = "magic-link-pending";
    type Pending = { otpId: string; otpEncryptionTargetBundle: string };

    export const savePendingMagicLink = async (p: Pending) =>
      localStorage.setItem(KEY, JSON.stringify(p));

    export const loadPendingMagicLink = async (): Promise<Pending | null> => {
      const raw = localStorage.getItem(KEY);
      return raw ? JSON.parse(raw) : null;
    };
    ```

    ### `google-oauth-flow.web.tsx` [#google-oauth-flowwebtsx]

    The web `useAuthenticateOAuth` hook runs a popup instead of a deep link. It returns to the same page that started auth, so it takes no arguments and does not need a dedicated `/oauth-callback` route on web:

    ```tsx
    import { OAUTH_PROVIDERS, useAuthenticateOAuth } from "@zerodev/wallet-react";
    import { Button, Text, View } from "react-native";
    import { useAccount } from "wagmi";

    export function GoogleOauthFlow() {
      const { status } = useAccount();
      const auth = useAuthenticateOAuth();

      if (status === "connected") return null;

      return (
        <View style={{ gap: 8, padding: 16, borderWidth: 1, borderRadius: 8 }}>
          <Button
            title={auth.isPending ? "Signing in..." : "Continue with Google"}
            disabled={auth.isPending}
            onPress={() => auth.mutate({ provider: OAUTH_PROVIDERS.GOOGLE })}
          />
          {auth.error ? <Text style={{ color: "red" }}>{auth.error.message}</Text> : null}
        </View>
      );
    }
    ```

    ### `wallet-export.web.tsx` [#wallet-exportwebtsx]

    Web export uses the `useExportWallet` / `useExportPrivateKey` hooks (in place of the native [`ZeroDevExportWebView`](/wallets/react-native/export-wallet)). They render the Turnkey iframe into a DOM node, which a `<View nativeID>` becomes under react-native-web:

    ```tsx
    import { useExportPrivateKey, useExportWallet } from "@zerodev/wallet-react";
    import { Button, Text, View } from "react-native";
    import { useAccount } from "wagmi";

    const CONTAINER = "zd-export-container";

    export function WalletExport() {
      const { status } = useAccount();
      const wallet = useExportWallet();
      const key = useExportPrivateKey();

      if (status !== "connected") return null;

      return (
        <View style={{ gap: 12, padding: 16, borderWidth: 1, borderRadius: 8 }}>
          <Button
            title={wallet.isPending ? "Exporting…" : "Export seed phrase"}
            disabled={wallet.isPending || key.isPending}
            onPress={() => wallet.mutate({ iframeContainerId: CONTAINER })}
          />
          <Button
            title={key.isPending ? "Exporting…" : "Export private key"}
            disabled={wallet.isPending || key.isPending}
            onPress={() => key.mutate({ iframeContainerId: CONTAINER })}
          />
          <View nativeID={CONTAINER} style={{ minHeight: 240, width: "100%" }} />
        </View>
      );
    }
    ```

    ### Cross-platform components [#cross-platform-components]

    Components with no RN-only imports — `passkey-flow.tsx` and `magic-link-flow.tsx` — need no `.web` variant; the same file runs on both platforms. For magic link, configure the Magic Link Redirect URL in the ZeroDev Dashboard for each origin you support, such as your native App Link domain and your web app origin:

    ```ts
    // Native: https://wallet.example.com/verify-email
    // Web: https://app.example.com/verify-email
    ```
  </div>

  <div className="fd-step">
    ## Route only the callbacks that still matter on web [#route-only-the-callbacks-that-still-matter-on-web]

    * **Web OAuth** uses a popup and returns to the same page that started auth. No dedicated `app/oauth-callback.tsx` route is required for the web flow.
    * **Magic link** still needs a real `app/verify-email.tsx` screen on both web and native, because the emailed link lands there with `?code=...`.
    * **Native OAuth** still needs `app/oauth-callback.tsx` if the same Expo app also runs on iOS/Android.

    If your **root layout is a tab navigator**, non-tab routes like `app/verify-email.tsx` never mount — the URL changes but the home tab keeps rendering, so `useVerifyMagicLink` never fires. Move the tabs into a `(tabs)` group and make the root a `Stack`, so these routes mount over it:

    ```
    app/
      _layout.tsx          → providers + <Stack screenOptions={{ headerShown: false }} />
      (tabs)/
        _layout.tsx        → <AppTabs/>
        index.tsx          ← moved
        explore.tsx        ← moved
      verify-email.tsx     ← required for magic link on web + native
      oauth-callback.tsx   ← keep if the same app also supports native OAuth
    ```

    `(tabs)` is a groupless segment, so `/` and `/explore` are unchanged. On a web-only app you can omit `app/oauth-callback.tsx`; keep it if the same codebase also runs native OAuth. In either case, keep the providers wrapping the `Stack` so `app/verify-email.tsx` and any native callback screen can use the wallet hooks:

    ```tsx
    // app/_layout.tsx
    import "react-native-get-random-values";

    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { Stack } from "expo-router";
    import { WagmiProvider } from "wagmi";

    import { wagmiConfig } from "@/wagmi.config";

    const queryClient = new QueryClient();

    export default function RootLayout() {
      return (
        <WagmiProvider config={wagmiConfig}>
          <QueryClientProvider client={queryClient}>
            <Stack screenOptions={{ headerShown: false }} />
          </QueryClientProvider>
        </WagmiProvider>
      );
    }
    ```

    ```tsx
    // app/(tabs)/_layout.tsx
    import AppTabs from "@/components/app-tabs";

    export default function TabsLayout() {
      return <AppTabs />;
    }
    ```

    The route files themselves don't change. This restructure is what makes magic link work on **both** web and native, while still leaving room for `app/oauth-callback.tsx` in the native flow.
  </div>

  <div className="fd-step">
    ## Type-check the web files [#type-check-the-web-files]

    Expo's base tsconfig sets `customConditions: ["react-native"]`, so `tsc` resolves the **native** typings of `@zerodev/*` for every file — including `.web` ones, where the web-only hooks and connector shape won't match (even though Metro bundles the right build at runtime). Check the two sets of files in separate passes.

    `tsconfig.json` — exclude the web files from the native pass:

    ```jsonc
    {
      "extends": "expo/tsconfig.base",
      "compilerOptions": {
        "strict": true,
        "paths": { "@/*": ["./src/*"], "@/assets/*": ["./assets/*"] }
      },
      "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
      "exclude": ["**/*.web.ts", "**/*.web.tsx"]
    }
    ```

    `tsconfig.web.json` — `customConditions: []` makes `@zerodev/*` resolve its web (`import`) typings:

    ```jsonc
    {
      "extends": "expo/tsconfig.base",
      "compilerOptions": {
        "strict": true,
        "customConditions": [],
        "paths": { "@/*": ["./src/*"], "@/assets/*": ["./assets/*"] }
      },
      "include": ["src/**/*.web.ts", "src/**/*.web.tsx", "expo-env.d.ts"]
    }
    ```

    Add a script that runs both passes, and use it instead of a bare `tsc`:

    ```jsonc
    "scripts": { "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.web.json" }
    ```

    > A bare `tsc` (and Expo's default) only runs the native config, which *excludes* the `.web` files — so they'd go unchecked. Editor types still resolve correctly, since each file is included by exactly one config.
  </div>

  <div className="fd-step">
    ## Allowlist your web origin [#allowlist-your-web-origin]

    Redirect and origin allowlists are origin-specific, so add your web origins next to the native entries on the [ZeroDev Dashboard](https://dashboard.zerodev.app/):

    * **OAuth** — allowlist the web origin (dev `http://localhost:8081`, plus any deployed URL).
    * **Magic link** — allowlist `<web-origin>/verify-email` as a redirect URL.
  </div>
</div>

## Optional troubleshooting [#optional-troubleshooting]

Most apps will not need this. Only apply it if the web bundle throws `Cannot destructure property '__extends' of 'tslib.default'`.

That error comes from a transitive dependency (`tsyringe`, pulled in via `@turnkey/crypto`) hitting a `tslib` ESM-interop bug under Metro's web bundler — it's not a ZeroDev requirement. Add `tslib` to your dependencies and point web at its self-contained ESM build:

```js
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");

const config = getDefaultConfig(__dirname);

config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (platform === "web" && moduleName === "tslib") {
    return context.resolveRequest(
      context,
      require.resolve("tslib/tslib.es6.js"),
      platform,
    );
  }
  return context.resolveRequest(context, moduleName, platform);
};

module.exports = config;
```

Then restart Metro with a cleared cache: `npx expo start -c`.

## Next Steps [#next-steps]

* [Connector Options](/wallets/connector-options) — Every `zeroDevWallet` option and its web default
* [Configuration](/wallets/react-native/configuration) — Stampers, storage adapters, and connector options on native
* [Magic Link](/wallets/react-native/magic-link) — The native magic-link flow
