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, just make the redirectURL point at the right origin per platform:

const redirectURL =
  Platform.OS === "web"
    ? `${window.location.origin}/verify-email`
    : `https://${RP_ID}/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