React Native 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-domandreact-native-webinpackage.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
.websiblings 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
Stacksoapp/verify-email.tsxcan mount - type-check
.webfiles 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.tsxroute is required for the web flow. - Magic link still needs a real
app/verify-email.tsxscreen on both web and native, because the emailed link lands there with?code=.... - Native OAuth still needs
app/oauth-callback.tsxif 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.json — customConditions: [] 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.webfiles — 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-emailas 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
- Connector Options — Every
zeroDevWalletoption and its web default - Configuration — Stampers, storage adapters, and connector options on native
- Magic Link — The native magic-link flow