Cross-Platform Web3 dApps With React Native

This article focuses on the ability to build cross-platform (web, iOS, Android) web3 dApps with React Native. If you need a more basic setup like implementing Viem, Wagmi, or WalletConnect in a React Native app, you're better off with these articles:

What is a cross-platform app?

A cross-platform application is an application developed to work on various platforms, including browsers and operating systems. It was popularized by frameworks such as React Native, which allowed developers to build native apps using JavaScript for both iOS and Android.

Embracing a cross-platform approach proves beneficial, especially if your team already manages a React web application and aims to develop a native mobile version. Why maintain separate codebases when React Native for Web offers a streamlined solution?

Within the same React Native codebase that powers your iOS and Android applications, React Native for Web will transpile your <View /> into a <div />, keeping your native features, and your components’ behavior, accessibility, and styles.

The current dApp situation

Currently, the dApp market is dominated by web apps. Looking at some of the top decentralized apps across multiple chains in DappRadar, it's easy to notice that only a select few (mostly big players for a long time) have their respective native mobile experiences. Smaller and younger apps are still going for a good enough responsive web experience instead.

And they're not wrong. Historically, it was difficult to spin up a Web3 mobile app due to the number of issues one would encounter when trying to install Web3 libraries, which were made primarily for the web.

For example, MetaMask SDK - the most used Ethereum wallet - requires installing several browser polyfills and patching it with rn-nodeify, which will traverse modules in node_modules and manually change files. The security implications are concerning, to say the least, especially for Web3 apps dealing with sensitive information.

Here's a quick comparison:

some of the top decentralized apps and the existence of native apps, listed in a table

On the other hand, it's nice to notice that some newer dApps on Lens Protocol (such as Orb and LensPlay) are going for a mobile-first approach, with LensPlay being built with React Native. But then, the web experience is missing.

I strongly believe that the next generation of Web3 dApps will use the same codebase for web, iOS, and Android, and will do it through Expo + React Native for Web.

Previous feasibility

Yes, Expo and React Native for Web aren't newcomers to the scene. If you've been following the React Native ecosystem over the past few years, you know it's not a new thing. Even the X (formerly Twitter) web app is built on React Native for Web.

So why wasn't it used before? The missing pieces of the puzzle held developers back.

When you're building a dApp, you’ll usually need three base pieces:

  1. A low-level library to interact with the blockchain, like Viem, Ethers, or web3.js
  2. A library to help connect to wallets: Web3Modal, RainbowKit, and ConnectKit are a few of the most famous ones
  3. and a collection of hooks, for better DX. Wagmi is the go-to, but there are alternatives like web3-react

The problem is that it was difficult to combine these three pieces and also make it work on both the web and React Native, while also maintaining design consistency between all platforms.

WalletConnect already had Web3Modal working great on the web but with their recent release of Web3Modal for React Native, this gap is closed.

Web3Modal is built on top of Wagmi (which is consequently built on Viem). Having options for both the web and native makes it easy to conditionally use both depending on the platform, while also maintaining the design/branding consistency that a production application needs.

Building a cross-platform app with React Native

We're going to be using Expo, which allows us to create cross-platform React Native apps in a breeze (on top of React Native for Web), and also has a ton of curated APIs available.

You can check the final code in the repository.

At the end, you’ll have a cross-platform dApp looking like this:

displaying features on both platforms

Requirements

  • Expo environment setup (Node.js, Git, Watchman)
  • A WalletConnect Cloud project ID
  • Expo Go app installed on your smartphone
  • One or more web3 wallets installed in your smartphone and browser (e.g. MetaMask, Rainbow Wallet, Trust Wallet, etc.)

Creating a new Expo app

Start by creating a new Expo app:

npx create-expo-app rn-cross-platform-dapp

And then, install the dependencies required for the web development:

npx expo install react-dom react-native-web @expo/webpack-config
npx expo install is a special command that install the dependencies respecting the versions that are supported by the version of the Expo SDK you're using

After, rename the App.js file to App.tsx. Expo will know we're trying to use TypeScript and will ask you to install the dependencies when you try to run the app.

In two different terminal sessions, run the app on your mobile platform of choice and on the web.

# iOS
npm run ios
# Android
npm run android
# Web
npm run web

If everything is working correctly you should see a blank app:

new blank Expo app on web and native

Setting up navigation

We want navigation to be best adapted to each platform. On native we’ll go for Tabs while using a fixed Drawer on web.

For that, we’ll use React Navigation, the default navigation method in React Native, also maintained by Callstack.

Install React Navigation:

npm install @react-navigation/native@next
Notice that we're using the @next flag. This will install the latest version of the library, React Navigation 7, which is needed because it contains fixes related to gesture handling on web.

and dependencies for Expo-managed projects:

npx expo install react-native-screens react-native-safe-area-context

Then, install the Tab Navigator:

npm install @react-navigation/bottom-tabs@next

And finally, install the Drawer Navigator:

npm install @react-navigation/drawer@next
npx expo install react-native-gesture-handler react-native-reanimated

The Drawer Navigator depends on Gesture Handler and Reanimated, so we need to finish setting them up.

At the very top of App.tsx, import Gesture Handler:

import 'react-native-gesture-handler'; // Add this
import React from 'react';
import { StatusBar } from 'expo-status-bar';
export default function App() {
...
}
view raw App.tsx hosted with ❤ by GitHub

And in babel.config.js, we need to add the Reanimated plugin:

module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'], // Add this
};
};
view raw babel.config.js hosted with ❤ by GitHub

Done with the installation, let's define our routes. To keep things organized, create a /src/navigation directory.

In src/navigation/index.ts, create both the TabNavigator and the DrawerNavigator:

import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createDrawerNavigator } from '@react-navigation/drawer';
export type ParamList = {
Home: undefined;
Balance: undefined;
Notifications: undefined;
Messages: undefined;
Bookmarks: undefined;
Profile: undefined;
};
export const RootTab = createBottomTabNavigator<ParamList>();
export const RootDrawer = createDrawerNavigator<ParamList>();
view raw index.ts hosted with ❤ by GitHub

With the ParamList type, we're telling the navigators that those keys will be their route names, and undefined means that none of those routes can receive parameters when navigating to them.

In your app, you might change the route names as you prefer and also pass complex types to the route parameters as they fit best.

Now, let's define the navigator structure. Create src/navigation/RootNavigator.tsx and add the following:

import React from 'react';
import { Platform } from 'react-native';
import { RootDrawer, RootTab } from './index';
const screens = [
{
name: 'Home',
component: () => null,
},
{
name: 'Balance',
component: () => null,
},
{
name: 'Notifications',
component: () => null,
},
{
name: 'Messages',
component: () => null,
},
{
name: 'Bookmarks',
component: () => null,
},
{
name: 'Profile',
component: () => null,
},
] as const;
const RootNavigator = () => {
if (Platform.OS === 'web') {
return (
<RootDrawer.Navigator>
{screens.map(screen => (
<RootDrawer.Screen
key={screen.name}
name={screen.name}
component={screen.component}
/>
))}
</RootDrawer.Navigator>
);
}
return (
<RootTab.Navigator>
{screens.map(screen => (
<RootTab.Screen
key={screen.name}
name={screen.name}
component={screen.component}
/>
))}
</RootTab.Navigator>
);
};
export default RootNavigator;

Here, notice how we're rendering different navigators based on the result of Platform.OS. We do this due to the fact that mobile and web apps use different navigation patterns.

The actual screens are defined in the screens array on top and iterated on each navigator. For now, the screen components are empty but we will implement them later on.

Now, we must wrap our navigator with NavigationContainer to display it on the screen. In App.tsx, add this:

import 'react-native-gesture-handler';
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import RootNavigator from './src/navigation/RootNavigator';
export default function App() {
return (
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
);
}
view raw App.tsx hosted with ❤ by GitHub

And if you run the app at this stage, you should see something like this:

basic navigation structure on web and native

But we're not done yet. We need to improve a few things to be closer to what a user would expect from a production application.

First, theming. Create src/theme.ts and override React Navigation’s colors for a dark theme. I chose a tone of purple as the primary color, but you can use whatever colors you prefer.

import { DarkTheme } from '@react-navigation/native';
const reactNavigationTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
primary: 'rgb(150,122,255)',
background: 'rgb(0, 0, 0)',
card: 'rgb(0, 0, 0)',
text: 'rgb(255, 255, 255)',
border: 'rgb(35,35,35)',
notification: 'rgb(255, 69, 58)',
},
};
const theme = {
colors: {
...reactNavigationTheme.colors,
disabled: 'rgb(52,50,50)',
},
};
export { reactNavigationTheme, theme };
view raw theme.ts hosted with ❤ by GitHub

Then apply it on App.tsx:

import 'react-native-gesture-handler';
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import RootNavigator from './src/navigation/RootNavigator';
import {reactNavigationTheme} from "./src/theme";
export default function App() {
return (
{/* Add theme here */}
<NavigationContainer theme={reactNavigationTheme}>
<RootNavigator />
</NavigationContainer>
);
}
view raw App.tsx hosted with ❤ by GitHub

And all your navigation components should now be in dark mode with a purple accent.

theme applied to the navigation structure

Then we need to make the drawer a fixed sidebar (instead of clicking to open) and customize the navigation icons.

To do that, let's first create a new component to encapsulate the navigation icons. Create a new src/components/NavIcon.tsx and add the following:

import React from 'react';
import { StyleSheet, View } from 'react-native';
import Octicons from '@expo/vector-icons/Octicons';
import { theme } from '../theme';
import { ParamList } from './index';
type NavIconProps = {
screenName: keyof ParamList;
focused: boolean;
};
export function getIconForRoute(
routeName: string,
): keyof typeof Octicons.glyphMap {
switch (routeName) {
case 'Home':
return 'apps';
case 'Balance':
return 'ruby';
case 'Notifications':
return 'bell';
case 'Messages':
return 'mail';
case 'Bookmarks':
return 'bookmark';
case 'Profile':
return 'person';
default:
return 'question';
}
}
function NavIcon({ screenName, focused }: NavIconProps) {
return (
<View style={styles.container}>
<Octicons
name={getIconForRoute(screenName)}
size={20}
color={focused ? theme.colors.primary : theme.colors.text}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
});
export default NavIcon;
view raw NavIcon.tsx hosted with ❤ by GitHub

This component simply renders an icon from the Octicons pack with a little breathing room. The getIconForRoute() function chooses an icon name for each route.

Going back to RootNavigator.tsx, let's use the new icon component and add a few more options.

import React from 'react';
import { Platform } from 'react-native';
import { ParamList, RootDrawer, RootTab } from './index';
import NavIcon from './NavIcon';
...
const RootNavigator = () => {
if (Platform.OS === 'web') {
return (
<RootDrawer.Navigator
screenOptions={{
header: () => null, // Hide drawer header
drawerType: 'permanent', // Drawer as a fixed sidebar
}}>
{screens.map(screen => (
<RootDrawer.Screen
key={screen.name}
name={screen.name}
component={screen.component}
options={{
// Add custom icons to drawer items
drawerIcon: ({ focused }) => (
<NavIcon screenName={screen.name} focused={focused} />
),
}}
/>
))}
</RootDrawer.Navigator>
);
}
return (
<RootTab.Navigator>
{screens.map(screen => (
<RootTab.Screen
key={screen.name}
name={screen.name}
component={screen.component}
options={{
// Hide tab bar labels
tabBarLabel: () => null,
// Add custom icons to tab bar items
tabBarIcon: ({ focused }) => (
<NavIcon screenName={screen.name} focused={focused} />
),
}}
/>
))}
</RootTab.Navigator>
);
};
export default RootNavigator;

Notice the options added indicated by the comments. In the Drawer navigator, we're removing the header (as it looks too mobile-ish), making the drawer a fixed sidebar, and using the new icon component we just created. In the Tab navigator, we're hiding the tab labels and also using the new icon component.

After adding these changes, your app should look like this. It's a great starting point for building the rest of our features!

navigation with fixed sidebar and custom icons

Installing Web3Modal

Start by installing Wagmi and Viem, as they are dependencies for the Web3Modal SDK. Then, install Web3Modal both for web and native.

# Install Wagmi and Viem, dependencies for Web3Modal
npm install wagmi viem
# Install Web3Modal web SDK
npm install @web3modal/wagmi
# Install Web3Modal React Native SDK, dependencies and polyfills
npx expo install @web3modal/wagmi-react-native @react-native-async-storage/async-storage react-native-get-random-values react-native-svg react-native-modal @react-native-community/netinfo @walletconnect/react-native-compat

In the upcoming configuration, we are going to use environment variables. Usually in Expo, you can just define variables in your .env starting with EXPO_PUBLIC and they will be available to use as process.env.EXPO_PUBLIC_MY_VARIABLE, but there is currently an issue with Expo web that we need to resolve.

Expo web is not replacing process.env variables, causing the values to be undefined at runtime. To solve that, create a new file called app.config.js to the root and add this:

// Expo web is not replacing process.env variables.
// Issue: https://github.com/expo/expo/issues/23812
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.startsWith('EXPO_PUBLIC_')),
);
module.exports = ({ config }) => {
return {
...config,
extra: { ...env },
};
};
view raw app.config.js hosted with ❤ by GitHub

This logic filters entries in process.env that start with EXPO_PUBLIC and re-exports them in Expo Config Extra field, making it available through Constants.expoConfig.extra.EXPO_PUBLIC_MY_VARIABLE.

Now, we need to conditionally import each Web3Modal SDK based on the running platform. To do that, we’ll create a src/web3modal directory and use Platform-specific file extensions, where index.ts will be imported when running on native, and index.web.ts will be imported when running on web.

Let's start by defining common variables for both. In src/web3modal/common.ts, add the following:

import { arbitrum, mainnet, polygon, polygonMumbai } from 'viem/chains';
import Constants from 'expo-constants';
const projectId =
Constants.expoConfig.extra.EXPO_PUBLIC_WALLETCONNECT_CLOUD_PROJECT_ID;
const metadata = {
name: 'Web3Modal cross-platform',
description: 'Web3Modal RN + web cross-platform example',
url: 'https://web3modal.com',
icons: ['https://avatars.githubusercontent.com/u/37784886'],
redirect: {
native: 'YOUR_APP_SCHEME://',
universal: 'YOUR_APP_UNIVERSAL_LINK.com',
},
};
const chains = [mainnet, polygon, polygonMumbai, arbitrum];
export { projectId, metadata, chains };
view raw common.ts hosted with ❤ by GitHub

Here, notice the following:

  1. The projectId comes from your WalletConnect Cloud project settings. It's free, and you can create one on your own. Create a .env file and add the value to a variable called EXPO_PUBLIC_WALLETCONNECT_CLOUD_PROJECT_ID.
  2. metadata is the information that will be passed to the wallet client when trying to connect the dApp
  3. chains is the list of chains that will be supported by the dApp

After that, create src/web3modal/index.ts:

import {
createWeb3Modal,
defaultWagmiConfig,
useWeb3Modal,
W3mButton,
Web3Modal,
} from '@web3modal/wagmi-react-native';
import { chains, metadata, projectId } from './common';
// Create Wagmi config
const wagmiConfig = defaultWagmiConfig({ chains, projectId, metadata });
// Init Web3Modal RN SDK
createWeb3Modal({
projectId,
chains,
wagmiConfig,
});
// Re-export components
export { wagmiConfig, useWeb3Modal, W3mButton, Web3Modal };
view raw index.ts hosted with ❤ by GitHub

Here we just initiated the Web3Modal React Native SDK using the common variables previously defined and re-exported the components we are going to use later.

Now let's create src/web3modal/index.web.ts:

import React from 'react';
import {
createWeb3Modal,
defaultWagmiConfig,
useWeb3Modal,
} from '@web3modal/wagmi/react';
import { chains, metadata, projectId } from './common';
// Create Wagmi config
const wagmiConfig = defaultWagmiConfig({ chains, projectId, metadata });
// Init Web3Modal Web SDK
createWeb3Modal({
projectId,
chains,
wagmiConfig,
});
// On web, export W3mButton as the globally-available web component
const W3mButton = () => <w3m-button />;
// On web, modal doesn't need to be rendered because it's a globally-available web component
const Web3Modal = () => null;
// Re-export components
export { wagmiConfig, useWeb3Modal, W3mButton, Web3Modal };
view raw index.web.ts hosted with ❤ by GitHub

See that, since the Web3Modal web SDK uses Web Components, they are globally available and don't need to be imported.

But since these index files are going to be imported conditionally depending on the platform, we need to export the exact same variables otherwise the application will crash during runtime.

So, on web, we define components with the same name as in the native SDK and re-export using the globally-available web components. That's why you don't see a <w3m-button /> being imported from anywhere.

Using the same platform-specific file extension concept, we will add polyfills necessary for when running on native.

To the root of your project, add polyfills.web.ts, and fill in with a comment:

// No polyfills needed for web

Even though there are no polyfills needed for the web implementation, we need to add a dummy file so the Metro bundler can resolve the module correctly according to the platform.

Then, on polyfills.ts, add the real polyfill:

// ⚠️ Important: `@walletconnect/react-native-compat` needs to be imported before other `wagmi` packages.
// This is because it applies a polyfill necessary for the TextEncoder API.
import '@walletconnect/react-native-compat';
view raw polyfills.ts hosted with ❤ by GitHub

Now that we have the base Web3Modal implementation ready, go to App.tsx, import the polyfills, and render the Wagmi provider and Web3Modal:

import './polyfills'; // Import the polyfills
import 'react-native-gesture-handler';
import React from 'react';
import { WagmiConfig } from 'wagmi';
import { NavigationContainer } from '@react-navigation/native';
import { Web3Modal, wagmiConfig } from './src/web3modal';
import RootNavigator from './src/navigation/RootNavigator';
import { reactNavigationTheme } from './src/theme';
export default function App() {
return (
// Render the Wagmi provider
<WagmiConfig config={wagmiConfig}>
{/* Render the modal */}
<Web3Modal />
<NavigationContainer theme={reactNavigationTheme}>
<RootNavigator />
</NavigationContainer>
</WagmiConfig>
);
}
view raw App.tsx hosted with ❤ by GitHub

Preparing base components

Before we move on, let's get some basic components ready so we can use them in the next sections.

First, we need a custom Text component with a default white color, since we're using dark theme to avoid having to pass color: 'white' on every usage.

In src/components/Text/index.tsx, add this:

import React from 'react';
import { Text as RNText, TextProps as RNTextProps } from 'react-native';
import { theme } from '../../theme';
type TextProps = RNTextProps & {
disabled?: boolean;
color?: string;
};
const Text = ({
disabled = false,
color = theme.colors.text,
...props
}: TextProps) => {
return (
<RNText
{...props}
style={[
{ color },
disabled && { color: theme.colors.disabled },
props.style,
]}
/>
);
};
export default Text;
view raw index.tsx hosted with ❤ by GitHub

Create a Logo component so we can get a better production feel. It's nothing fancy, just stylized text. If you want, load an image or SVG.

In src/components/Logo/index.tsx, add this:

import React from 'react';
import { StyleSheet } from 'react-native';
import Text from '../Text';
import { theme } from '../../theme';
function Logo() {
return <Text style={styles.logo}>Native3</Text>;
}
const styles = StyleSheet.create({
logo: {
fontWeight: '900',
fontStyle: 'italic',
letterSpacing: -2.5,
fontSize: 24,
color: theme.colors.primary,
},
});
export default Logo;
view raw index.tsx hosted with ❤ by GitHub

And last, we’re gonna need a Separator component for a list that will come later.

In src/components/Separator/index.tsx, add this:

import React from 'react';
import { StyleSheet, View } from 'react-native';
function Separator() {
return <View style={styles.separator} />;
}
const styles = StyleSheet.create({
separator: {
height: 20,
},
});
export default Separator;
view raw index.tsx hosted with ❤ by GitHub

Connecting the wallet

Finally, we need to render the actual button to connect the wallet. To be able to access it from everywhere, we’ll put it on the sidebar on web, and on the header on native. We’ll also add the Logo component created before.

Create a new file src/navigation/DrawerContent.tsx. We’ll override the Drawer navigator sidebar items, adding the Logo on top, and Connect Wallet button on the bottom.

import React from 'react';
import {
DrawerContentScrollView,
DrawerItemList,
} from '@react-navigation/drawer';
import { StyleSheet, View } from 'react-native';
import { W3mButton } from '../web3modal';
import Logo from '../components/Logo';
function CustomDrawerContent(props) {
return (
<DrawerContentScrollView {...props}>
{/* Logo on top */}
<View style={styles.logoContainer}>
<Logo />
</View>
{/* List items in the middle */}
<DrawerItemList {...props} />
{/* Connect button on bottom */}
<View style={styles.w3mButton}>
<W3mButton />
</View>
</DrawerContentScrollView>
);
}
const styles = StyleSheet.create({
logoContainer: {
marginVertical: 20,
marginLeft: 24,
},
w3mButton: {
marginTop: 20,
marginLeft: 18,
},
});
export default CustomDrawerContent;
Notice how we can import the W3mButton from the module we created before with Platform-specific file extensions. While on native it's importing the button from the Web3Modal native SDK, on web is importing the global Web Component.

For the header on native, we’ll keep the Logo on the left and the button on the right.

Create a new file src/navigation/TabHeader.tsx and add the following:

import React from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { W3mButton } from '../web3modal';
import Logo from '../components/Logo';
function TabHeader() {
const insets = useSafeAreaInsets();
return (
<View
style={[
styles.container,
{
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
},
]}>
{/* Logo on the left */}
<Logo />
{/* Button on the right */}
<View style={styles.w3mButton}>
<W3mButton />
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 20,
},
w3mButton: {
marginLeft: 'auto',
},
});
export default TabHeader;
view raw TabHeader.tsx hosted with ❤ by GitHub

Here, see that we're using useSafeAreaInsets() to avoid the header getting cut off by a device notch or similar. To make it work, we need to add the SafeAreaProvider to the app root.

In App.tsx, add the provider:

import './polyfills';
import 'react-native-gesture-handler';
import React from 'react';
import { WagmiConfig } from 'wagmi';
import { NavigationContainer } from '@react-navigation/native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Web3Modal, wagmiConfig } from './src/web3modal';
import RootNavigator from './src/navigation/RootNavigator';
import { reactNavigationTheme } from './src/theme';
export default function App() {
return (
<WagmiConfig config={wagmiConfig}>
<Web3Modal />
{/* Add the SafeAreaProvider */}
<SafeAreaProvider>
<NavigationContainer theme={reactNavigationTheme}>
<RootNavigator />
</NavigationContainer>
</SafeAreaProvider>
</WagmiConfig>
);
}
view raw App.tsx hosted with ❤ by GitHub

And last, we need to actually use those new navigation components in our RootNavigator.tsx.

...
const RootNavigator = () => {
if (Platform.OS === 'web') {
return (
<RootDrawer.Navigator
drawerContent={DrawerContent} // Add the DrawerContent
screenOptions={{
header: () => null,
drawerType: 'permanent',
}}>
...
</RootDrawer.Navigator>
);
}
return (
<RootTab.Navigator
screenOptions={{
header: () => <TabHeader />, // Add the TabHeader
}}>
...
</RootTab.Navigator>
);
};
export default RootNavigator;

That was a lot. If you managed to follow through here, congrats! 🎉

Our dApp is in a good initial shape, and should be looking similar to this:

connecting wallet on web and native

Displaying NFTs

Our Home screen will show a list of the NFTs owned by the connected wallet.

Start by creating a new file: src/features/home/HomeScreen.tsx.

import React from 'react';
import { Button, StyleSheet, View } from 'react-native';
import { useAccount, useSignMessage } from 'wagmi';
import Text from '../../components/Text';
import { theme } from '../../theme';
import NftList from './NftList';
export default function HomeScreen() {
const { isConnected } = useAccount();
const { isSuccess: isSigned, signMessage } = useSignMessage({
message: 'Sign this message to prove you are the owner of this wallet',
});
if (!isConnected) {
return (
<View style={styles.container}>
<Text>Connect wallet to display NFTs</Text>
</View>
);
}
if (!isSigned) {
return (
<View style={styles.container}>
<Button
title="Sign Message"
color={theme.colors.primary}
onPress={() => signMessage()}
/>
</View>
);
}
return <NftList />;
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 20,
},
});
view raw HomeScreen.tsx hosted with ❤ by GitHub
Remember to import the Text component from our /components directory, created previously. If you use the standard Text from React Native, nothing will appear on the screen because the default color is black.

Notice that, since Web3Modal is built on top of Wagmi, we can leverage all Wagmi hooks!

Here's what is being done on this screen:

  1. Fetch whether the wallet isConnected by calling useAccount().
  2. If not connected, early return with a message.
  3. Fetch whether a message is signed, by calling useSignMessage(). This is usually done to check the user is the actual owner of the connected wallet.
  4. If the message is not signed, display a button to sign.
  5. If the message is signed, continue to the <NftList />.

Before we continue to the NFT list, we need to set up a custom provider from where we're going to query this data nicely indexed from the blockchain. We’ll use Alchemy, but you could use Zora or another provider of preference.

Install the Alchemy SDK:

npm install alchemy-sdk

Then, in polyfills.ts, add the polyfills for atob and btoa, two web APIs for encoding and decoding base64 to binary.

// ⚠️ Important: `@walletconnect/react-native-compat` needs to be imported before other `wagmi` packages.
// This is because Web3Modal has a polyfill necessary for the TextEncoder API.
import '@walletconnect/react-native-compat';
// Polyfills for Alchemy SDK
if (typeof btoa === 'undefined') {
global.btoa = function (str) {
return new Buffer(str, 'binary').toString('base64');
};
}
if (typeof atob === 'undefined') {
global.atob = function (b64Encoded) {
return new Buffer(b64Encoded, 'base64').toString('binary');
};
}
view raw polyfills.ts hosted with ❤ by GitHub

Now, under /src/services, create a new file called alchemy.ts:

import { useEffect, useState } from 'react';
import { Alchemy, Network, Nft } from 'alchemy-sdk';
const alchemy = new Alchemy({
apiKey: process.env.EXPO_PUBLIC_ALCHEMY_API_KEY,
network: Network.ETH_MAINNET,
});
export function useNftsForAddress(address: string) {
const [isLoading, setIsLoading] = useState(true);
const [nfts, setNfts] = useState<Nft[]>([]);
useEffect(() => {
const getNfts = async () => {
const { ownedNfts } = await alchemy.nft.getNftsForOwner(address);
const filtered = ownedNfts.filter(nft => !!nft.image.thumbnailUrl);
setNfts(filtered);
setIsLoading(false);
};
if (address) {
getNfts();
}
}, [address]);
return { nfts, isLoading };
}
view raw alchemy.ts hosted with ❤ by GitHub

At the top, we initiate the Alchemy SDK. To start with, you’ll need a free API key. Once you get it, create a custom hook called useNftsForAddress, which - as the name says - takes in an address and uses the SDK to fetch NFTs for that address.

After that, we're ready to build our NFT list.

In src/features/home/NftList.tsx, add the following:

import React from 'react';
import { FlatList, Platform, StyleSheet, View } from 'react-native';
import { useAccount } from 'wagmi';
import Separator from '../../components/Separator';
import { useNftsForAddress } from '../../services/alchemy';
import Text from '../../components/Text';
import NftListItem from './NftListItem';
const NUM_COLUMNS = Platform.OS === 'web' ? 4 : 1;
function NftList() {
const { address } = useAccount();
let { nfts, isLoading } = useNftsForAddress(address);
const renderItem = ({ item }) => {
// Dynamically calculating width to avoid items having different widths
// if the last row is not full. Needed because the columns are generated
// by the FlatList itself.
const width: `${number}%` =
NUM_COLUMNS > 1 ? `${90 / NUM_COLUMNS}%` : '100%';
return <NftListItem nft={item} style={{ width }} />;
};
if (nfts.length === 0) {
return (
<View style={styles.emptyContainer}>
{isLoading ? (
<Text>Loading...</Text>
) : (
<Text>No NFTs were found for address: {address}</Text>
)}
</View>
);
}
return (
<FlatList
data={nfts}
numColumns={NUM_COLUMNS}
renderItem={renderItem}
keyExtractor={item => `${item.contract?.address}-${item.tokenId}`}
contentContainerStyle={styles.contentContainerStyle}
columnWrapperStyle={NUM_COLUMNS > 1 ? styles.columnWrapper : undefined}
ItemSeparatorComponent={Separator}
/>
);
}
const styles = StyleSheet.create({
contentContainerStyle: {
paddingVertical: 20,
paddingHorizontal: '5%',
},
columnWrapper: {
columnGap: 20,
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
export default NftList;
view raw NftList.tsx hosted with ❤ by GitHub

We're finally using that Separator component we created before.

This will fetch the NFTs for the address using the hook we just created, and render a FlatList with one column on native, and four columns on web. We also show a loading indicator if the SDK hasn't returned yet, and an empty text if the address doesn't own any NFTs.

Create a src/constants.ts file and add OPENSEA_BASE_URL, the URL of the marketplace that we're going to link to when pressing the NFT item.

// Base URL for the OpenSea marketplace
export const OPENSEA_BASE_URL = 'https://opensea.io/assets/ethereum';
view raw constants.ts hosted with ❤ by GitHub

And then, create the actual list item, in src/features/home/NftListItem.tsx:

import React from 'react';
import {Image, Linking, Pressable, StyleSheet, View, ViewStyle} from 'react-native';
import { Nft } from 'alchemy-sdk';
import Text from '../../components/Text';
import { theme } from '../../theme';
import { OPENSEA_BASE_URL } from '../../constants';
type NftListItemProps = {
nft: Nft;
style: ViewStyle;
};
function NftListItem({ nft, style }: NftListItemProps) {
const openMarketplace = () => {
Linking.openURL(
`${OPENSEA_BASE_URL}/${nft.contract.address}/${nft.tokenId}`,
);
};
return (
<Pressable
accessibilityRole="button"
onPress={openMarketplace}
style={[styles.container, style]}>
{/* @ts-expect-error: react-native-web does not export TS typings for the `hovered` argument */}
{({ hovered, pressed }) => (
<View style={(hovered || pressed) && styles.pressed}>
<Image
accessibilityIgnoresInvertColors
source={{ uri: nft.image.thumbnailUrl }}
style={styles.image}
/>
<View style={styles.nameContainer}>
<Text numberOfLines={1} style={styles.name}>
{nft.name}
</Text>
{nft.collection?.name && (
<Text numberOfLines={1}>{nft.collection.name}</Text>
)}
</View>
</View>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
borderWidth: 2,
borderRadius: 12,
borderColor: theme.colors.border,
},
pressed: {
opacity: 0.8,
},
nameContainer: {
justifyContent: 'center',
gap: 4,
padding: 10,
},
name: {
fontSize: 16,
fontWeight: 'bold',
lineHeight: 30,
},
image: {
height: 250,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
},
});
export default NftListItem;
view raw NftListItem.tsx hosted with ❤ by GitHub

Nothing complex here - just building up a list item that looks like a card and opens the respective NFT in the marketplace when pressed.

Finally, to be able to see this in action, we need to tell RootNavigator.tsx that we want to render the home screen:

...
const screens = [
{
name: 'Home',
component: HomeScreen, // Add this
},
{
name: 'Balance',
component: () => null,
},
...
] as const;

And you should end up with this:

NFT list on the home page
If you have sharp eyes, you're probably wondering how I show the same NFTs on both web and native if I connected 2 different wallets. You're right, that shouldn't be possible. I hardcoded the address behind the scenes for presentation purposes.

Summary

That was a long guide, but we hope that you can get a grasp of what is possible by leveraging the cross-platform React Native ecosystem.

Using Expo and React Native for Web, you can tailor your experience for each platform, while also sharing most of your codebase. With WalletConnect’s Web3Modal, you can offer hundreds of wallets to connect to your dApp on every platform while maintaining consistent UX.

It's a huge win for maintainability with effective code re-usage. For users, it provides delightful UI and UX in whatever device they use.

And with minimal additional effort, dApps can start reaching a whole new market share!

Insights

Learn more about Web3

Event Cover

Apr 5Talk

Build Universal Web3 Apps With React Native

Learn how to use React Native to build true cross-platform experiences (iOS, Android and Web) for your decentralized apps, from a single codebase.

cover

Mar 13Article

The Mini Guide to React Native Optimization for Web3

Explore the essential strategies for optimizing React Native apps in the web3 landscape with this insightful mini-guide. Enhance stability, security, and UX.

cover

Mar 6Article

Increase Speed and Security With Native Crypto Libraries

Boost speed and security in React Native with native crypto libraries. Uncover browser or Node.js API polyfill drawbacks, and find efficient solutions.

cover

Jan 12Article

How to Build Universal Lens Protocol Social Apps With React Native

Unlock the potential of web3 social apps with Lens Protocol on the Polygon blockchain. Learn how to build a universal social network app using React Native.

cover

Nov 14Podcast

Building the Future of Mobile Web3

Do Web3 and React Native go together? Tune in to our discussion about the possibilities & challenges of building cross-platform mobile apps with blockchain.

cover

Nov 13Article

Best DX for React Native Web3 dApps With Web3Modal and Wagmi

Unsatisfied with developer experience of developing for Ethereum? Learn how to build a React Native dApp using Wagmi and Web3Modal.

cover

Oct 25Article

Build Modern Web3 dApps on Ethereum With React Native and Viem

Learn to build modern web3 dApps on Ethereum using React Native and viem. Discover how viem outperforms traditional libraries.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.