Skip to content

React Native Web

Run your Expo app on the web

The Expo app from the quickstart also runs on the web via 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.

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

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

1. 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

The web connector needs only projectId and chains:

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

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

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

The web useAuthenticateOAuth 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:

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

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

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

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:

// Native: https://wallet.example.com/verify-email
// Web: https://app.example.com/verify-email

2. 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:

// 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>
  );
}
// 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.

3. 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:

{
  "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.jsoncustomConditions: [] makes @zerodev/* resolve its web (import) typings:

{
  "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:

"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.

4. 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:

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

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:

// 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