Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/pages/wallets/auth/google-oauth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
This page covers the web flow. For Expo / React Native, see [Google OAuth on React Native](/wallets/react-native/google-oauth).
:::

Google OAuth lets users sign in with their Google account using a popup-based flow. The SDK handles the popup, redirect back to your app, and session creation automatically.
Google OAuth lets users sign in with their Google account using a popup-based flow. The SDK handles the popup, returns it to the same route that started auth, and completes session creation automatically.

By default, the Google OAuth flow uses a Zerodev-managed Google account for authentication. This allows you to get started quickly without any additional configuration. You can customize the OAuth flow by providing your own Google OAuth client ID and secret in the developer dashboard.

> **Important:** Using custom credentials requires creating a new project with no wallets already created. You cannot switch an existing project from the default Zerodev-managed OAuth setup to a custom client configuration.

In the standard web flow, you do **not** need a dedicated `/oauth-callback` page. Render the auth button on the page that should receive the popup back, and keep the ZeroDev provider mounted there.

## Hook

- [`useAuthenticateOAuth`](/wallets/hooks/use-authenticate-oauth) — Trigger the OAuth flow
Expand Down Expand Up @@ -72,7 +74,7 @@ function GoogleAuth() {

3. **Open popup**: The SDK opens a popup to that verified Google login URL and polls the popup while the user signs in with Google.

4. **Popup redirect**: After a successful sign-in, the popup returns to your app's origin with `?oauth_success=true&session_id=...`.
4. **Popup redirect**: After a successful sign-in, the popup returns to the same origin and pathname that started auth, with `?oauth_success=true&session_id=...`.

5. **Complete auth**: The SDK reads `session_id` from the popup URL, completes wallet authentication with it, and then connects the Wagmi connector.

Expand Down
14 changes: 12 additions & 2 deletions docs/pages/wallets/hooks/use-authenticate-oauth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import MutationResult from '../shared/mutation-result.mdx'
**USE FOR INTERNAL TESTING PURPOSES ONLY.** You may use these features solely for internal evaluation purposes on supported testnets. DO NOT use for production use or share with your users. Wallets created during this preview ("Alpha Wallets") will be discontinued. Any tokens remaining within Alpha Wallets will be permanently lost upon discontinuance. Any mainnet tokens sent to an Alpha Wallet will not be deposited and will be permanently lost when discontinued. We are unable to help recover any lost funds from Alpha Wallets. We provide all previews on an "as is" basis without warranty of any kind, and we may terminate or suspend the availability of any preview at any time.
:::

On web this hook opens a popup and returns it to the current page. Unlike the React Native Expo hook, the common web flow does not require a separate callback route or a `redirectUri` parameter.

## Import

```tsx
Expand Down Expand Up @@ -44,7 +46,15 @@ function OAuthLogin() {
}
```

## Parameters
## Hook Parameters

### timeoutMs

`number | undefined`

Optional timeout for the popup polling flow, in milliseconds. Defaults to `300000` (5 minutes).

## Mutation Parameters

### provider

Expand All @@ -62,7 +72,7 @@ function OAuthLogin() {

`(variables: { provider: OAuthProvider }) => void`

The mutation function to start the OAuth flow. Opens a popup window for the user to sign in.
The mutation function to start the OAuth flow. Opens a popup window for the user to sign in, then waits for that popup to return to the current page's origin.

### mutateAsync

Expand Down
2 changes: 2 additions & 0 deletions docs/pages/wallets/react-native/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,5 @@ bunx expo install @react-native-async-storage/async-storage
- **Crypto polyfill** — import `react-native-get-random-values` at the very top of your app's entry file (see the [quickstart](/wallets/react-native/quickstart)).
- **Wallet export is a component, not a hook** — [`useExportWallet`](/wallets/hooks/use-export-wallet) and [`useExportPrivateKey`](/wallets/hooks/use-export-private-key) are not available on React Native. Use [`ZeroDevExportWebView`](/wallets/react-native/export-wallet) instead.
- **OAuth uses deep links** — instead of the web popup flow, use [`useAuthenticateOAuthWithExpoWebBrowser`](/wallets/hooks/use-authenticate-oauth-with-expo-web-browser).

Running the same Expo app on the web too? See [React Native Web](/wallets/react-native/web) — the web build auto-defaults the stampers and storage, so the connector is just `zeroDevWallet({ projectId, chains })`.
2 changes: 2 additions & 0 deletions docs/pages/wallets/react-native/google-oauth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export default function OAuthCallback() {
}
```

This route is for the **native** OAuth flow. If the same Expo app also runs on web, the `.web` variant should use [`useAuthenticateOAuth`](/wallets/hooks/use-authenticate-oauth) and return to the current page instead of relying on `app/oauth-callback.tsx`.

## 5. Allowlist the redirect URL

On the [ZeroDev Dashboard](https://dashboard.zerodev.app/), **allowlist the redirect URL**. After a successful sign-in the browser redirects back into the app.
Expand Down
8 changes: 5 additions & 3 deletions docs/pages/wallets/react-native/passkeys.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,16 @@ export const wagmiConfig = createConfig({

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

/** Renders nothing once connected; native-only (RN passkey stamper). */
/** Renders nothing once connected. The same component can be reused on web. */
export function PasskeyFlow() {
const { status } = useAccount();
const register = useRegisterPasskey();
const login = useLoginPasskey();

if (status === "connected" || Platform.OS === "web") return null;
if (status === "connected") return null;

return (
<View style={{ gap: 8, padding: 16, borderWidth: 1, borderRadius: 8 }}>
Expand All @@ -115,3 +115,5 @@ export function PasskeyFlow() {
```

The OS handles the passkey UI (biometric/PIN); the hooks auto-connect the wallet on success. The device needs Google Play services and an enrolled screen lock.

If the same Expo app also runs on web, keep this component shared. The [React Native Web guide](/wallets/react-native/web) uses the same `PasskeyFlow` and lets the web build auto-default its WebAuthn stamper, so you do not need a separate `.web` variant for the passkey UI.
1 change: 1 addition & 0 deletions docs/pages/wallets/react-native/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,4 @@ These guides demonstrate the native flows on Android because a debug build runs
- [Magic Link](/wallets/react-native/magic-link) — Sign in with a link sent by email
- [Passkeys](/wallets/react-native/passkeys) — Native WebAuthn
- [Export Wallet](/wallets/react-native/export-wallet) — Reveal the seed phrase or private key via WebView
- [React Native Web](/wallets/react-native/web) — Run the same app on the web with react-native-web
290 changes: 290 additions & 0 deletions docs/pages/wallets/react-native/web.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
# React Native Web [Run your Expo app on the web]

:::danger[IMPORTANT]
**USE FOR INTERNAL TESTING PURPOSES ONLY.** You may use these features solely for internal evaluation purposes on supported testnets. DO NOT use for production use or share with your users. Wallets created during this preview ("Alpha Wallets") will be discontinued. Any tokens remaining within Alpha Wallets will be permanently lost upon discontinuance. Any mainnet tokens sent to an Alpha Wallet will not be deposited and will be permanently lost when discontinued. We are unable to help recover any lost funds from Alpha Wallets. We provide all previews on an "as is" basis without warranty of any kind, and we may terminate or suspend the availability of any preview at any time.
:::

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.

:::info[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).
:::

## 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`:

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

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`

The web [`useAuthenticateOAuth`](/wallets/hooks/use-authenticate-oauth) 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`

Web export uses the [`useExportWallet`](/wallets/hooks/use-export-wallet) / [`useExportPrivateKey`](/wallets/hooks/use-export-private-key) 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

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:

```ts
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:

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

## 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:

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

## 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](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.

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

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