How to Build Universal Lens Protocol Social Apps With React Native

In this article we will focus on building a universal (web, iOS, Android) web3 social network app on Lens Protocol with React Native. If you need a standard web3 cross-platform setup, see this  guide for more in-depth explanation:

Special thanks to Nader Dabit and Vivek Suthar for reviewing the final repository regarding Lens Protocol best practices.

What is Lens Protocol?

Lens Protocol is the building blocks for building web3 social applications. It is a web3 social graph on the Polygon blockchain, and it seeks to solve major problems with existing social networks, which all have their centralized databases and fight for your attention and content.

In Lens, users own their data. Since users’ profiles, publications, and reactions live in the protocol, they can read and write content to any application built on top of the protocol, and it will be available everywhere. Each application built on Lens benefits the whole ecosystem, and developers can design meaningful social experiences without the need to turn to feedback mechanisms to lock in the user's attention.

For example, Orb is a client focused on the mobile experience, communities and creators. Buttrfly is an explorer for both web and mobile. And Tape is a platform focused on video content. While all of them share your data on the Lens Protocol, each have their own set of features and tailored experience.

This is how my profile looks on each of them. They share the data but optimize the UX according to each app's focus.

Same profile shown on Buttrfly, Tape and Orb

Building a universal social app

We’ll start off from a web3 cross-platform setup similar to what we've built previously in the article mentioned above. This will save us time from setting up custom navigation for each platform (sidebar on web, tabs on mobile) and setting up Web3Modal on both platforms to connect the wallet.

If you want to check the final result directly, see the final code.

To follow along with this article, download the final code and switch to the article-start branch, which is a clean state branch without the social app features we will build in the following sections.

git checkout article-start

Our setup uses Expo to create a cross-platform app, WalletConnect Web3Modal to connect the wallet and Wagmi for useful Ethereum hooks. You may want to read the article on how to set up cross-platform web3 apps first to understand the reasoning behind this setup.

In the end, you’ll have a universal social app with a simple feed and profile page, running on native and web.

Universal social app on Lens Protocol

Installing the Lens SDK

Let's start by installing the Lens Protocol SDK. The SDK reference is here, and you can read more about all Lens concepts in the docs.

# Install the Lens web SDK
yarn add @lens-protocol/react-web@alpha
# Install the Lens React Native SDK
yarn add @lens-protocol/react-native@latest
# Install the Wagmi companion package
yarn add @lens-protocol/wagmi@alpha
Notice that you don't need to install ethers@legacy-v5 as suggested in the SDK reference because we will be using Viem instead.

Also install Expo SecureStore - a safe alternative to AsyncStorage - so we can safely store our SDK access tokens.

npx expo install expo-secure-store

With the packages installed, we're ready to create a cross-platform module. Similar to src/lib/web3modal, we’ll create a src/lib/lens-sdk module and use Platform-specific file extensions so the Metro bundler can import either the web or native SDK conditionally based on the platform the code is running on.

In src/lib/lens-sdk/index.ts add the code that is going to run on native:

import { LensConfig, development } from '@lens-protocol/react-native'; // <-- Import the react-native package
import { bindings as wagmiBindings } from '@lens-protocol/wagmi';
import * as SecureStore from 'expo-secure-store';
const lensConfig: LensConfig = {
bindings: wagmiBindings(),
// Use `development` or `production` depending on your need
environment: development,
storage: {
getItem: SecureStore.getItemAsync,
setItem: SecureStore.setItemAsync,
removeItem: SecureStore.deleteItemAsync,
},
};
// Re-export everything from the Lens native SDK, as well as the config
export * from '@lens-protocol/react-native';
export { lensConfig };
view raw index.ts hosted with ❤ by GitHub

In src/lib/lens-sdk/index.web.ts add the code that is going to run on web:

import { LensConfig, development } from '@lens-protocol/react-web'; // <-- Import the web package
import { bindings as wagmiBindings } from '@lens-protocol/wagmi';
const lensConfig: LensConfig = {
bindings: wagmiBindings(),
// Use `development` or `production` depending on your need
environment: development,
};
// Re-export everything from the Lens web SDK, as well as the config
export * from '@lens-protocol/react-web';
export { lensConfig };
view raw index.web.ts hosted with ❤ by GitHub

Notice that both are very similar, with the major difference being that we change the SDK that we are importing on the first line, and on native we are using SecureStore as the storage mechanism.

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 might crash during runtime if it’s trying to access something that doesn't exist.

To finalize the Lens SDK setup, we just need to add the LensProvider to our App.tsx.

...
import { lensConfig, LensProvider } from './src/lib/lens-sdk';
export default function App() {
return (
<WagmiConfig config={wagmiConfig}>
{/* Add the provider with the config */}
<LensProvider config={lensConfig}>
<StatusBar style="auto" />
<Web3Modal />
<SafeAreaProvider>
<NavigationContainer theme={reactNavigationTheme}>
<RootNavigator />
</NavigationContainer>
</SafeAreaProvider>
</LensProvider>
</WagmiConfig>
);
}
view raw App.tsx hosted with ❤ by GitHub

Remember to import everything related to Lens from our recently-created lens-sdk module, so imports are resolved correctly depending on the platform.

Easy enough, if you run the app at this point, you should see a blank template. We are going to add the new screens in the next section.

# iOS
npm run ios
# Android
npm run android
# Web
npm run web
Alt text: the cross-platform blank template

Sign-in flow

Now that we have the Lens SDK ready to use, let's build the sign-in flow.

Each wallet can manage multiple Lens profiles, dictated by the presence of Lens profile NFTs or by delegating the permissions to another wallet. So we need to connect the wallet, query the available profiles, and display an interface for the user to choose which one to log in with.

First, in src/lib/navigation/RootNavigator.tsx, let's block our routes. If the user is authenticated, render all the routes normally, otherwise render the SignInScreen.

...
import SignInScreen from '../features/sign-in/SignInScreen';
import { SessionType, useSession } from '../lens-sdk';
const screens = [
...
] as const;
const RootNavigator = () => {
const { data: session } = useSession(); // Get the session
// Check if user is authenticated
const authenticated =
session?.authenticated && session?.type === SessionType.WithProfile;
if (Platform.OS === 'web') {
return (
<RootDrawer.Navigator
drawerContent={DrawerContent}
screenOptions={{
header: () => null,
drawerType: 'permanent',
}}>
{/* If authenticated, render all the routes normally, otherwise, render only the SignIn route */}
{authenticated ? (
screens.map(screen => (
<RootDrawer.Screen
key={screen.name}
name={screen.name}
component={screen.component}
options={{
drawerIcon: ({ focused }) => (
<NavIcon screenName={screen.name} focused={focused} />
),
}}
/>
))
) : (
<RootDrawer.Screen
name="SignIn"
component={SignInScreen}
options={{
title: 'Sign In',
drawerIcon: ({ focused }) => (
<NavIcon screenName="SignIn" focused={focused} />
),
}}
/>
)}
</RootDrawer.Navigator>
);
}
return (
<RootTab.Navigator
screenOptions={{
header: () => <TabHeader />,
}}>
{/* If authenticated, render all the routes normally, otherwise, render only the SignIn route */}
{authenticated ? (
screens.map(screen => (
<RootTab.Screen
key={screen.name}
name={screen.name}
component={screen.component}
options={{
tabBarLabel: () => null,
tabBarIcon: ({ focused }) => (
<NavIcon screenName={screen.name} focused={focused} />
),
}}
/>
))
) : (
<RootTab.Screen
name="SignIn"
component={SignInScreen}
options={{
title: 'Sign In',
tabBarIcon: ({ focused }) => (
<NavIcon screenName="SignIn" focused={focused} />
),
}}
/>
)}
</RootTab.Navigator>
);
};
export default RootNavigator;

To make TypeScript happy, we need to go back to src/lib/navigation/index.ts and add a SignIn key to the ParamList type, so our router knows that it is a possible route.

import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createDrawerNavigator } from '@react-navigation/drawer';
export type ParamList = {
SignIn: undefined; // Add the SignIn route
Home: 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

And finally, let's build the actual SignInScreen in src/features/sign-in/SignInScreen.tsx.

import React from 'react';
import { StyleSheet, View } from 'react-native';
import { useAccount } from 'wagmi';
import Text from '../../components/Text';
import { useLastLoggedInProfile, useProfilesManaged } from '../../lib/lens-sdk';
import ProfileCard from './ProfileCard'; // This component does not exist yet, we'll create it next
export default function SignInScreen() {
// Get the connected wallet address
const { isConnected, address } = useAccount();
// Query the profiles managed by the connected wallet address
const { data: profiles = [] } = useProfilesManaged({
for: address,
});
// Query the last logged in profile for the connected wallet address
const { data: lastLoggedInProfile } = useLastLoggedInProfile({
for: address,
});
// Sort the profiles so that the last logged in profile is at the top
const sortedProfiles = [...profiles].sort((a, b) => {
if (a.id === lastLoggedInProfile?.id) {
return -1;
}
if (b.id === lastLoggedInProfile?.id) {
return 1;
}
return 0;
});
return (
<View style={styles.container}>
{isConnected ? (
<>
<Text>Choose profile to sign in with</Text>
{/* Render a profile card for each profile */}
{sortedProfiles.map(profile => (
<ProfileCard
key={profile.id}
profile={profile}
isLastLoggedInProfile={profile.id === lastLoggedInProfile?.id}
/>
))}
</>
) : (
<Text>Connect wallet to sign in</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 20,
gap: 20,
},
});

Here's the breakdown of what is happening in this screen:

  1. Using the Wagmi hook useAccount(), we get the connected wallet address
  2. Using the Lens SDK hook useProfilesManaged(), we get all the profiles managed by that wallet address
  3. Using the Lens SDK hook useLastLoggedInProfile(), as the name suggests, we get the last profile used to sign in by that wallet address
  4. Then, to achieve a better UX, we sort the profiles to put the last one used on top of the list
  5. And finally, we render a ProfileCard for each profile, displaying the name, handle, and a button to sign in.

Next we need to create the missing ProfileCard component. In src/features/sign-in/ProfileCard.tsx, add this:

import React from 'react';
import { ImageBackground, StyleSheet, View } from 'react-native';
import { Image } from 'expo-image';
import Octicons from '@expo/vector-icons/Octicons';
import Text from '../../components/Text';
import { Profile, ProfilePictureSet } from '../../lib/lens-sdk';
import { LensButton } from '../../components/LensButton'; // This component does not exist yet, we'll create it next
import { theme } from '../../lib/theme';
type ProfileCardProps = {
profile: Profile;
isLastLoggedInProfile: boolean;
};
function ProfileCard({ profile, isLastLoggedInProfile }: ProfileCardProps) {
const coverImageUri = profile.metadata?.coverPicture?.optimized?.uri;
const avatarImageUri = (profile.metadata?.picture as ProfilePictureSet)
?.thumbnail?.uri;
return (
// Display the cover image as the background of the card
<ImageBackground
style={[
styles.container,
{
borderColor: isLastLoggedInProfile
? theme.colors.primary
: theme.colors.border,
},
]}
source={{
uri: coverImageUri,
}}>
<View style={styles.overlay} />
<View style={styles.innerContainer}>
<View style={styles.profileContainer}>
{/* Display the profile avatar */}
{avatarImageUri ? (
<Image
accessibilityIgnoresInvertColors
source={{ uri: avatarImageUri }}
style={styles.avatarImage}
/>
) : (
<View style={styles.avatarImage}>
<Octicons name="person" size={16} color={theme.colors.disabled} />
</View>
)}
<View style={styles.textContainer}>
{/* Display the profile name */}
{profile.metadata?.displayName && (
<Text style={styles.name} numberOfLines={1}>
{profile.metadata.displayName}
</Text>
)}
{/* Display the profile handle */}
{profile.handle?.localName && (
<Text numberOfLines={1}>@{profile.handle?.localName}</Text>
)}
{/* Display the last used profile indicator */}
{isLastLoggedInProfile && (
<Text style={styles.lastUsedProfile}>Last used</Text>
)}
</View>
</View>
{/* Display the button to sign in */}
<LensButton profileId={profile.id} />
</View>
</ImageBackground>
);
}
const styles = StyleSheet.create({
container: {
maxWidth: 800,
width: '100%',
height: 100,
borderWidth: 2,
borderRadius: 10,
borderCurve: 'continuous',
overflow: 'hidden',
},
overlay: {
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: theme.colors.overlay,
borderWidth: 2,
borderRadius: 10,
borderCurve: 'continuous',
},
innerContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
},
profileContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
textContainer: {
alignItems: 'flex-start',
justifyContent: 'center',
gap: 4,
},
name: {
fontWeight: 'bold',
},
avatarImage: {
width: 50,
height: 50,
borderRadius: 10,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
},
lastUsedProfile: {
fontSize: 10,
color: theme.colors.primary,
},
});
export default ProfileCard;
view raw ProfileCard.tsx hosted with ❤ by GitHub

The component receives the actual profile and a boolean isLastLoggedInProfile as props. Deconstructing the profile, we display the name via profile.metadata.displayName and handle via profile.handle?.localName. Inside the metadata you can also find the URIs for the cover image - rendered as the background of the card - and avatar image, rendered to the left.

In the end, we just need something to show the essential information and a button to sign in when pressed. You can style it any way you want.

Speaking of the button to sign in, the last remaining piece is creating the LensButton component. In src/components/LensButton/index.tsx, add the following:

import React from 'react';
import { useLogin } from '@lens-protocol/react-native';
import { useAccount } from 'wagmi';
import { ProfileId } from '../../lib/lens-sdk';
import Button from '../Button';
type LensButtonProps = {
profileId: ProfileId;
};
export function LensButton({ profileId }: LensButtonProps) {
const { address } = useAccount();
const { execute: login, loading: loginLoading } = useLogin();
const onLogin = async () => {
const loginResult = await login({
address: address,
profileId,
});
if (loginResult.isFailure()) {
console.log('login error:', loginResult.error.message);
return;
}
};
return (
<Button title="Sign in 🌱" loading={loginLoading} onPress={onLogin} />
);
}
view raw index.tsx hosted with ❤ by GitHub

See that it's just a wrapper on top of the generic Button component. When pressed, it uses the Lens SDK hook useLogin() to authenticate with Lens Protocol using the wallet address and profileId.

If you run the app at this point, we should be able to see our profile cards, connect with a profile, and see the protected routes.

sign-in flow demonstration

Claiming a testnet profile

In order to test the sign in flow like above, you will need a Lens Profile. While in production Lens Profiles are currently in closed beta and are invite-only, profiles on the testnet are free and available for anyone to grab, perfect for builders.

To claim a testnet profile, go to testnet.hey.xyz, connect your wallet, press “Login” and then “Create a testnet account” and enter your desired handle.

claiming a testnet profile on Hey

Profile page

Now let's build the Profile page, to show the authenticated profile followers and following count as well as a way for the user to log out.

In src/features/profile/ProfileScreen.tsx, add:

import React from 'react';
import { ImageBackground, StyleSheet, View } from 'react-native';
import { Image } from 'expo-image';
import Text from '../../components/Text';
import Button from '../../components/Button';
import SignInScreen from '../sign-in/SignInScreen';
import { theme } from '../../lib/theme';
import {
ProfilePictureSet,
SessionType,
useLogout,
useSession,
} from '../../lib/lens-sdk';
export default function ProfileScreen() {
// Get the session
const { data: session } = useSession();
// Get the logout handler
const { execute: logout, loading: logoutLoading } = useLogout();
if (!session?.authenticated) {
return null;
}
if (session?.type !== SessionType.WithProfile) {
return <SignInScreen />;
}
// Get the cover and avatar images
const profile = session.profile;
const coverImageUri = profile.metadata?.coverPicture?.optimized?.uri;
const avatarImageUri = (profile.metadata?.picture as ProfilePictureSet)
?.thumbnail?.uri;
return (
<View style={styles.container}>
{/* Display the cover image in the background */}
<ImageBackground
accessibilityIgnoresInvertColors
style={styles.coverImage}
source={{ uri: coverImageUri }}>
<View style={styles.overlay} />
{/* Display the avatar image in the middle */}
<Image
accessibilityIgnoresInvertColors
style={styles.avatarImage}
source={{ uri: avatarImageUri }}
/>
{/* Display the name and handle */}
<View style={styles.nameContainer}>
<Text style={styles.name}>{profile.metadata?.displayName}</Text>
<Text style={styles.handle}>@{profile.handle?.localName}</Text>
</View>
{/* Display following and followers count */}
<View style={styles.statsContainer}>
<View style={styles.statsItem}>
<Text>Following</Text>
<Text>{profile.stats.following}</Text>
</View>
<View style={styles.statsItem}>
<Text>Followers</Text>
<Text>{profile.stats.followers}</Text>
</View>
</View>
</ImageBackground>
{/* Display a button to logout */}
<View style={styles.body}>
<Button
title="Logout"
color="danger"
loading={logoutLoading}
onPress={logout}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
marginTop: 20,
},
coverImage: {
alignItems: 'center',
justifyContent: 'center',
gap: 10,
height: 300,
width: '100%',
backgroundColor: theme.colors.disabled,
},
overlay: {
position: 'absolute',
height: 300,
width: '100%',
backgroundColor: theme.colors.overlay,
},
avatarImage: {
height: 100,
width: 100,
borderRadius: 30,
borderCurve: 'continuous',
},
nameContainer: {
gap: 4,
},
name: {
textAlign: 'center',
fontWeight: 'bold',
},
handle: {
textAlign: 'center',
},
statsContainer: {
flexDirection: 'row',
gap: 8,
alignItems: 'center',
},
statsItem: {
flexDirection: 'row',
gap: 4,
alignItems: 'center',
},
body: {
marginTop: 20,
alignItems: 'center',
justifyContent: 'center',
},
});
  1. Similarly to the ProfileCard built before, we get the profile information from the metadata.
  2. Then extract from profile.stats to get the followers and following count.
  3. Finally, get the logout handler from the useLogout() hook and attach it to a button.

After adding that, navigate to the profile tab. You should see something like this:

profile page

Feed of publications

For the last part, we're going to build the most important component of a social application, the home feed. Luckily, Lens makes this very easy with the useFeed() hook.

In src/features/home/HomeScreen.tsx, add this:

import React from 'react';
import { StyleSheet, View } from 'react-native';
import { useFeed, useSession, ProfileSession } from '../../lib/lens-sdk';
import Text from '../../components/Text';
import FeedList from './FeedList'; // This component does not exist yet, we'll create it next
export default function HomeScreen() {
const { data: session } = useSession();
// Get the feed for the logged in profile
const { data, loading } = useFeed({
where: {
for: (session as ProfileSession).profile?.id,
},
});
// If no data, feed is either loading or empty (not following anyone)
if (data?.length === 0) {
return (
<View style={styles.emptyContainer}>
{loading ? <Text>Loading...</Text> : <Text>Your feed is empty ☹️</Text>}
</View>
);
}
return (
<View style={styles.container}>
<FeedList feed={data} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
marginHorizontal: 20,
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
view raw HomeScreen.tsx hosted with ❤ by GitHub

For the HomeScreen, it's going to be as simple as that. We query the feed using the Lens SDK hook useFeed() and pass it to FeedList. If there is no data, the feed is either loading or empty (user is not following anyone), so we display a message.

In the same directory, create FeedList.tsx:

import React from 'react';
import { FlatList, StyleSheet } from 'react-native';
import Separator from '../../components/Separator';
import { FeedItem } from '../../lib/lens-sdk';
import PublicationItem from './PublicationItem'; // This component does not exist yet, we'll create it next
type FeedListProps = {
feed: FeedItem[];
};
function FeedList({ feed = [] }: FeedListProps) {
const renderItem = ({ item }) => {
return <PublicationItem item={item} />;
};
return (
<FlatList
data={feed}
renderItem={renderItem}
keyExtractor={item => item.id}
contentContainerStyle={styles.contentContainerStyle}
ItemSeparatorComponent={Separator}
/>
);
}
const styles = StyleSheet.create({
contentContainerStyle: {
paddingVertical: 20,
},
});
export default FeedList;
view raw FeedList.tsx hosted with ❤ by GitHub

No mystery here too, this component just renders a FlatList in which each item is a PublicationItem, so let's create it next.

Each PublicationItem looks like what you would expect from a social media post. A card with the author info and publication content:

example publication card

In the same directory, create PublicationItem.tsx:

import React from 'react';
import { StyleSheet, View } from 'react-native';
import Text from '../../components/Text';
import { theme } from '../../lib/theme';
import { FeedItem, Post } from '../../lib/lens-sdk';
import PublicationProfile from './PublicationProfile'; // This component does not exist yet, we'll create it next
import PublicationReactions from './PublicationReactions'; // This component does not exist yet, we'll create it next
type PostItemProps = {
item: FeedItem;
};
function PublicationItem({ item }: PostItemProps) {
const publication = item.root as Post;
const profile = publication.by;
// For simplicity, filtering out EventMetadataV3 items because they do not have content
if (publication.metadata.__typename === 'EventMetadataV3') {
return null;
}
return (
<View style={styles.container}>
{/* Display profile information for who posted the publication */}
<PublicationProfile profile={profile} />
{/* Display the actual publication content */}
<View style={styles.contentContainer}>
<Text>{publication.metadata?.content}</Text>
</View>
{/* Display the reactions for the publication with a button to toggle upvote */}
<PublicationReactions publication={publication} />
</View>
);
}
const styles = StyleSheet.create({
container: {
minHeight: 100,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
borderCurve: 'continuous',
padding: 12,
},
contentContainer: {
marginTop: 12,
marginLeft: 62,
},
});
export default PublicationItem;

For simplicity, we're filtering certain types of publications. We're also ignoring media such as pictures or videos.

As we've seen in the image above, this component displays the profile information that posted the publication, the actual publication content text, and the amount of upvotes (likes) the publication has, alongside a button to toggle the reaction.

We're almost there! The last remaining pieces are creating the two missing components:

  • PublicationProfile, to take care of the upper part of the card with the autor info
  • PublicationReactions, to take care of showing the total upvotes and a button to toggle the reaction

Still in the same directory, create PublicationProfile.tsx:

import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import Octicons from '@expo/vector-icons/Octicons';
import { theme } from '../../lib/theme';
import Text from '../../components/Text';
import { Profile, ProfilePictureSet } from '../../lib/lens-sdk';
type PublicationProfileProps = {
profile: Profile;
};
function PublicationProfile({ profile }: PublicationProfileProps) {
const avatarImageUri = (profile.metadata?.picture as ProfilePictureSet)
?.thumbnail?.uri;
return (
<View style={styles.profileContainer}>
{/* Display author avatar */}
{avatarImageUri ? (
<Image
accessibilityIgnoresInvertColors
source={{ uri: avatarImageUri }}
style={styles.avatarImage}
/>
) : (
<View style={styles.avatarImage}>
<Octicons name="person" size={16} color={theme.colors.disabled} />
</View>
)}
{/* Display author name and handle */}
<View style={styles.textContainer}>
{profile.metadata?.displayName && (
<Text style={styles.name} numberOfLines={1}>
{profile.metadata.displayName}
</Text>
)}
{profile.handle?.localName && (
<Text numberOfLines={1}>@{profile.handle?.localName}</Text>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
profileContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 12,
},
textContainer: {
alignItems: 'flex-start',
justifyContent: 'center',
gap: 4,
},
name: {
fontWeight: 'bold',
},
avatarImage: {
width: 50,
height: 50,
borderRadius: 10,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
},
});
export default PublicationProfile;

Then, in PublicationReactions.tsx, add the following:

import React from 'react';
import { View, StyleSheet, Pressable } from 'react-native';
import Octicons from '@expo/vector-icons/Octicons';
import { theme } from '../../lib/theme';
import Text from '../../components/Text';
import {
Post,
PublicationReactionType,
useReactionToggle,
} from '../../lib/lens-sdk';
type PublicationProfileProps = {
publication: Post;
};
function PublicationProfile({ publication }: PublicationProfileProps) {
// Get the toggle reaction handler
const { execute: toggleReaction } = useReactionToggle();
// On press, toggle the reaction passing the publication
const toggleUpvote = async () => {
await toggleReaction({
reaction: PublicationReactionType.Upvote,
publication,
});
};
return (
<View style={styles.container}>
<Pressable
accessibilityRole="button"
onPress={toggleUpvote}
// @ts-expect-error: react-native-web does not export TS typings for the `hovered` argument
style={({ pressed, hovered }) => [
styles.reactionButton,
{
backgroundColor:
pressed || hovered ? theme.colors.lightWhite : 'transparent',
},
]}>
{/* @ts-expect-error: react-native-web does not export TS typings for the `hovered` argument */}
{({ pressed, hovered }) => (
<Octicons
name="thumbsup"
size={16}
color={
publication.operations.hasUpvoted || pressed || hovered
? theme.colors.primary
: theme.colors.disabled
}
/>
)}
</Pressable>
{/* Display the total upvotes */}
<Text
style={{
color: publication.operations.hasUpvoted
? theme.colors.primary
: theme.colors.disabled,
}}>
{publication.stats.upvotes}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginTop: 12,
marginLeft: 54,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
reactionButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
});
export default PublicationProfile;

And that's it! Run the app and you should see the final result:

final result, showing the home feed

Summary

Congrats! You now have a cross-platform web3 social app running on the Lens Protocol.

Most of the magic relies on the platform specific modules we created, both for the Lens SDK and for Web3Modal. That allows us to leverage different SDKs depending on the platform.

By combining the power of the Expo APIs and React Native for Web, teams building web3 experiences can start targeting cross-platform users right from the start. This not only caters to a broader audience. It also makes development smoother by preparing the app architecture for multiple platforms early on instead of having to plug-in in a complex system later. We hope to see your next app on-chain!

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

Dec 28Article

Cross-Platform Web3 dApps With React Native

Discover the power of cross-platform development with React Native and Expo in this guide. Learn to build Web3 dApps for web, iOS, and Android.

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.