Thursday, July 3, 2025
Complete Clerk Authentication with expo Setup Guide
Posted by
Complete Clerk Authentication with expo Setup Guide
This guide will walk you through setting up Clerk authentication in your React Native Expo app from scratch.
š¦ Step 1: Install Required Packages
First, install all the necessary packages:
# Create a new expo app with desishub cli
desishub new my-expo-app
# Core Clerk package
pnpm install @clerk/clerk-expo
# Required dependencies
pnpm install expo-crypto expo-secure-store expo-web-browser expo-auth-session
# Form handling
pnpm install react-hook-form @hookform/resolvers zod
š§ Step 2: Configure Clerk
2.1 Create Clerk Account and App
- Go to clerk.com and create an account
- Create a new application
- Copy your publishable key from the dashboard
2.2 Set up Environment Variables
- Create a .env file in your project root:
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY = pk_test_your_key_here;
2.3 Configure OAuth Providers (Optional)
In your Clerk dashboard:
- Go to "User & Authentication" ā "Social Connections"
- Enable Google, GitHub, Apple, or Facebook
- Follow the setup instructions for each provider
Add Clerk Provider to your Root Layout
import "@/global.css";
import { useColorScheme } from "@/hooks/useColorScheme";
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { ClerkProvider } from "@clerk/clerk-expo";
import { createNotifications } from "react-native-notificated";
import { tokenCache } from "@clerk/clerk-expo/token-cache";
import "react-native-reanimated";
export default function RootLayout() {
const colorScheme = useColorScheme();
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
const { NotificationsProvider, useNotifications, ...events } =
createNotifications();
if (!loaded) {
// Async font loading only occurs in development.
return null;
}
const queryClient = new QueryClient();
return (
<ClerkProvider tokenCache={tokenCache}>
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView>
<NotificationsProvider>
<ThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="(front-pages)"
options={{ headerShown: false }}
/>
<Stack.Screen
name="(dashboard-pages)"
options={{ headerShown: false }}
/>
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</NotificationsProvider>
</GestureHandlerRootView>
{/* <Toast /> */}
</QueryClientProvider>
</ClerkProvider>
);
}
Layout page
-
First, protect your sign-up and sign-in pages.
-
Create an
(auth)
route group. This will group your sign-up and sign-in pages. In the(auth)
group, create a_layout.tsx
file with the following code. TheuseAuth()
hook is used to access the user's authentication state. If the user is already signed in, they will be redirected to the home page.
import { Redirect, Stack } from "expo-router";
import { useAuth } from "@clerk/clerk-expo";
export default function AuthRoutesLayout() {
const { isSignedIn } = useAuth();
if (isSignedIn) {
return <Redirect href={"/"} />;
}
return <Stack />;
}
šØ Step 3: Create UI Components
- Create a components/ui folder and add these files:
components/ui/Input.tsx
import { Ionicons } from "@expo/vector-icons";
import React, { forwardRef } from "react";
import {
Text,
TextInput,
TextInputProps,
TouchableOpacity,
View,
} from "react-native";
interface InputProps extends TextInputProps {
label?: string;
error?: string;
leftIcon?: keyof typeof Ionicons.glyphMap;
rightIcon?: keyof typeof Ionicons.glyphMap;
onRightIconPress?: () => void;
containerClassName?: string;
}
export default forwardRef<TextInput, InputProps>(function Input(
{
label,
error,
leftIcon,
rightIcon,
onRightIconPress,
containerClassName = "",
...props
},
ref
) {
return (
<View className={`mb-4 ${containerClassName}`}>
{label && (
<Text className="text-gray-800 text-base font-medium mb-2">
{label}
</Text>
)}
<View className="relative">
<View
className={`flex-row items-center bg-white border-2 rounded-xl px-4 py-4 ${
error ? "border-red-400" : "border-gray-200"
}`}
>
{leftIcon && (
<Ionicons
name={leftIcon}
size={20}
color={error ? "#f87171" : "#9ca3af"}
style={{ marginRight: 12 }}
/>
)}
<TextInput
ref={ref}
className="flex-1 text-gray-900 text-base"
placeholderTextColor="#9ca3af"
{...props}
/>
{rightIcon && (
<TouchableOpacity onPress={onRightIconPress} className="ml-3">
<Ionicons
name={rightIcon}
size={20}
color={error ? "#f87171" : "#9ca3af"}
/>
</TouchableOpacity>
)}
</View>
</View>
{error && <Text className="text-red-500 text-sm mt-2 ml-1">{error}</Text>}
</View>
);
});
components/ui/Button.tsx
import React from "react";
import {
ActivityIndicator,
Text,
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
interface ButtonProps extends TouchableOpacityProps {
title: string;
loading?: boolean;
variant?: "primary" | "secondary" | "outline";
size?: "small" | "medium" | "large";
}
export default function Button({
title,
loading = false,
variant = "primary",
size = "large",
disabled,
...props
}: ButtonProps) {
const getButtonStyles = () => {
const baseStyles = "rounded-xl items-center justify-center";
// Size styles
const sizeStyles = {
small: "px-4 py-2",
medium: "px-6 py-3",
large: "px-6 py-4",
};
// Variant styles
const variantStyles = {
primary: disabled || loading ? "bg-gray-300" : "bg-black",
secondary:
disabled || loading
? "bg-gray-100 border border-gray-200"
: "bg-gray-100 border border-gray-200",
outline:
disabled || loading
? "bg-white border-2 border-gray-200"
: "bg-white border-2 border-black",
};
return `${baseStyles} ${sizeStyles[size]} ${variantStyles[variant]}`;
};
const getTextStyles = () => {
const baseStyles = "font-semibold";
// Size styles
const sizeStyles = {
small: "text-sm",
medium: "text-base",
large: "text-base",
};
// Variant styles
const variantStyles = {
primary: disabled || loading ? "text-gray-500" : "text-white",
secondary: disabled || loading ? "text-gray-400" : "text-gray-700",
outline: disabled || loading ? "text-gray-400" : "text-black",
};
return `${baseStyles} ${sizeStyles[size]} ${variantStyles[variant]}`;
};
return (
<TouchableOpacity
className={getButtonStyles()}
disabled={disabled || loading}
{...props}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === "primary" ? "#ffffff" : "#666666"}
/>
) : (
<Text className={getTextStyles()}>{title}</Text>
)}
</TouchableOpacity>
);
}
components/ui/SocialButton.tsx
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
interface SocialButtonProps {
provider: "google" | "apple" | "facebook" | "github";
onPress: () => void;
loading?: boolean;
disabled?: boolean;
}
export default function SocialButton({
provider,
onPress,
loading = false,
disabled = false,
}: SocialButtonProps) {
const getProviderConfig = () => {
switch (provider) {
case "google":
return {
icon: "logo-google" as const,
iconColor: "#4285F4",
text: "Continue with Google",
bgColor: "bg-white",
borderColor: "border-gray-200",
textColor: "text-gray-700",
};
case "apple":
return {
icon: "logo-apple" as const,
iconColor: "#000000",
text: "Sign in with Apple",
bgColor: "bg-white",
borderColor: "border-gray-200",
textColor: "text-gray-700",
};
case "facebook":
return {
icon: "logo-facebook" as const,
iconColor: "#ffffff",
text: "Continue with Facebook",
bgColor: "bg-blue-600",
borderColor: "border-blue-600",
textColor: "text-white",
};
case "github":
return {
icon: "logo-github" as const,
iconColor: "#333333",
text: "Continue with GitHub",
bgColor: "bg-white",
borderColor: "border-gray-200",
textColor: "text-gray-700",
};
default:
return {
icon: "link-outline" as const,
iconColor: "#666666",
text: "Continue",
bgColor: "bg-white",
borderColor: "border-gray-200",
textColor: "text-gray-700",
};
}
};
const config = getProviderConfig();
const isDisabled = disabled || loading;
return (
<TouchableOpacity
onPress={onPress}
disabled={isDisabled}
className={`
w-full flex-row items-center justify-center
${config.bgColor} border-2 ${config.borderColor}
rounded-xl py-4 px-6 mb-3
${isDisabled ? "opacity-50" : ""}
`}
>
{loading ? (
<ActivityIndicator
size="small"
color={provider === "facebook" ? "#ffffff" : "#666666"}
/>
) : (
<View className="flex-row items-center">
<Ionicons name={config.icon} size={20} color={config.iconColor} />
<Text className={`${config.textColor} font-medium text-base ml-3`}>
{config.text}
</Text>
</View>
)}
</TouchableOpacity>
);
}
š± Step 4: Create Authentication Screens
screens/SignInScreen.tsx
import { Ionicons } from "@expo/vector-icons";
import { useSignIn, useSSO } from "@clerk/clerk-expo";
import * as AuthSession from "expo-auth-session";
import * as WebBrowser from "expo-web-browser";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
SafeAreaView,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import SocialButton from "@/components/ui/SocialButton";
// Browser optimization hook
export const useWarmUpBrowser = () => {
useEffect(() => {
void WebBrowser.warmUpAsync();
return () => {
void WebBrowser.coolDownAsync();
};
}, []);
};
WebBrowser.maybeCompleteAuthSession();
interface SignInFormData {
email: string;
password: string;
}
interface SignInScreenProps {
onSignUp?: () => void;
}
export default function SignInScreen({ onSignUp }: SignInScreenProps) {
useWarmUpBrowser();
const { signIn, setActive, isLoaded } = useSignIn();
const { startSSOFlow } = useSSO();
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [socialLoading, setSocialLoading] = useState(false);
const {
control,
handleSubmit,
formState: { errors },
setError,
} = useForm<SignInFormData>({
defaultValues: {
email: "",
password: "",
},
});
const onSubmit = async (data: SignInFormData) => {
if (!isLoaded) return;
setLoading(true);
try {
const signInAttempt = await signIn.create({
identifier: data.email,
password: data.password,
});
if (signInAttempt.status === "complete") {
await setActive({ session: signInAttempt.createdSessionId });
router.replace("/(tabs)");
}
} catch (err: any) {
console.error("Sign in error:", JSON.stringify(err, null, 2));
setError("root", {
message: err.errors?.[0]?.message || "Invalid email or password",
});
} finally {
setLoading(false);
}
};
const handleSocialSignIn = useCallback(
async (strategy: "oauth_google" | "oauth_github" | "oauth_apple") => {
setSocialLoading(true);
try {
const { createdSessionId, setActive } = await startSSOFlow({
strategy,
redirectUrl: AuthSession.makeRedirectUri(),
});
if (createdSessionId) {
setActive!({ session: createdSessionId });
router.replace("/(tabs)");
}
} catch (err: any) {
console.error("Social sign in error:", JSON.stringify(err, null, 2));
setError("root", {
message: "Social sign in failed. Please try again.",
});
} finally {
setSocialLoading(false);
}
},
[]
);
return (
<SafeAreaView className="flex-1 bg-gray-50">
<ScrollView className="px-6" showsVerticalScrollIndicator={false}>
{/* Logo */}
<View className="items-center pt-16 pb-8">
<View className="w-16 h-16 bg-black rounded-2xl items-center justify-center mb-6">
<Ionicons name="home" size={24} color="white" />
</View>
<Text className="text-2xl font-bold text-gray-900 text-center mb-2">
Reales - Your Key to{"\n"}Seamless Real Estate
</Text>
<Text className="text-gray-600 text-center">
Sign in or register and we'll get started.
</Text>
</View>
{/* Form */}
<View className="mb-6">
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
pattern: {
value: /^\S+@\S+$/i,
message: "Invalid email address",
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<Input
label="Email"
placeholder="flowforge@gmail.com"
keyboardType="email-address"
autoCapitalize="none"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.email?.message}
/>
)}
/>
<Controller
control={control}
name="password"
rules={{ required: "Password is required" }}
render={({ field: { onChange, onBlur, value } }) => (
<Input
label="Password"
placeholder="ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢"
secureTextEntry={!showPassword}
rightIcon={showPassword ? "eye-off-outline" : "eye-outline"}
onRightIconPress={() => setShowPassword(!showPassword)}
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.password?.message}
/>
)}
/>
<TouchableOpacity className="mb-6">
<Text className="text-blue-600 text-right font-medium">
Forgot password?
</Text>
</TouchableOpacity>
{errors.root && (
<Text className="text-red-500 text-sm mb-4 text-center">
{errors.root.message}
</Text>
)}
<Button
title="Sign In"
onPress={handleSubmit(onSubmit)}
loading={loading}
/>
</View>
{/* Divider */}
<View className="flex-row items-center mb-6">
<View className="flex-1 h-px bg-gray-300" />
<Text className="px-4 text-gray-500">Or</Text>
<View className="flex-1 h-px bg-gray-300" />
</View>
{/* Social Buttons */}
<View className="mb-8">
<SocialButton
provider="apple"
onPress={() => handleSocialSignIn("oauth_apple")}
loading={socialLoading}
/>
<SocialButton
provider="google"
onPress={() => handleSocialSignIn("oauth_google")}
loading={socialLoading}
/>
<SocialButton
provider="github"
onPress={() => handleSocialSignIn("oauth_github")}
loading={socialLoading}
/>
</View>
{/* Sign Up Link */}
<View className="flex-row justify-center mb-8">
<Text className="text-gray-600">Not a member? </Text>
<TouchableOpacity onPress={onSignUp}>
<Text className="text-blue-600 font-medium">Register</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
}
screens/SignUpScreen.tsx
import { Ionicons } from "@expo/vector-icons";
import { useSignUp, useSSO } from "@clerk/clerk-expo";
import * as AuthSession from "expo-auth-session";
import * as WebBrowser from "expo-web-browser";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
SafeAreaView,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import SocialButton from "@/components/ui/SocialButton";
// Browser optimization hook
export const useWarmUpBrowser = () => {
useEffect(() => {
void WebBrowser.warmUpAsync();
return () => {
void WebBrowser.coolDownAsync();
};
}, []);
};
WebBrowser.maybeCompleteAuthSession();
interface SignUpFormData {
firstName: string;
lastName: string;
email: string;
password: string;
}
interface SignUpScreenProps {
onSignIn?: () => void;
}
export default function SignUpScreen({ onSignIn }: SignUpScreenProps) {
useWarmUpBrowser();
const { isLoaded, signUp, setActive } = useSignUp();
const { startSSOFlow } = useSSO();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [socialLoading, setSocialLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [pendingVerification, setPendingVerification] = useState(false);
const [code, setCode] = useState("");
const {
control,
handleSubmit,
formState: { errors },
setError,
} = useForm<SignUpFormData>({
defaultValues: {
firstName: "",
lastName: "",
email: "",
password: "",
},
});
const onSubmit = async (data: SignUpFormData) => {
if (!isLoaded) return;
setLoading(true);
try {
await signUp.create({
firstName: data.firstName,
lastName: data.lastName,
emailAddress: data.email,
password: data.password,
});
await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
setPendingVerification(true);
} catch (err: any) {
console.error("Sign up error:", JSON.stringify(err, null, 2));
setError("root", {
message: err.errors?.[0]?.message || "Something went wrong",
});
} finally {
setLoading(false);
}
};
const onVerifyPress = async () => {
if (!isLoaded) return;
setLoading(true);
try {
const signUpAttempt = await signUp.attemptEmailAddressVerification({
code,
});
if (signUpAttempt.status === "complete") {
await setActive({ session: signUpAttempt.createdSessionId });
router.replace("/(tabs)");
}
} catch (err: any) {
console.error("Verification error:", JSON.stringify(err, null, 2));
setError("root", {
message: err.errors?.[0]?.message || "Invalid verification code",
});
} finally {
setLoading(false);
}
};
const handleSocialSignUp = useCallback(
async (strategy: "oauth_google" | "oauth_github" | "oauth_apple") => {
setSocialLoading(true);
try {
const { createdSessionId, setActive } = await startSSOFlow({
strategy,
redirectUrl: AuthSession.makeRedirectUri(),
});
if (createdSessionId) {
setActive!({ session: createdSessionId });
router.replace("/(tabs)");
}
} catch (err: any) {
console.error("Social sign up error:", JSON.stringify(err, null, 2));
setError("root", {
message: "Social sign up failed. Please try again.",
});
} finally {
setSocialLoading(false);
}
},
[]
);
if (pendingVerification) {
return (
<SafeAreaView className="flex-1 bg-gray-50">
<ScrollView className="px-6" showsVerticalScrollIndicator={false}>
<View className="items-center pt-16 pb-8">
<View className="w-24 h-24 bg-blue-100 rounded-full items-center justify-center mb-6">
<Ionicons name="mail-outline" size={40} color="#3b82f6" />
</View>
<Text className="text-2xl font-bold text-gray-900 mb-2">
Check Your Email
</Text>
<Text className="text-gray-600 text-center mb-8">
We've sent a verification code to your email address.
</Text>
</View>
<Input
label="Verification Code"
placeholder="Enter 6-digit code"
value={code}
onChangeText={setCode}
keyboardType="number-pad"
maxLength={6}
/>
{errors.root && (
<Text className="text-red-500 text-sm mb-4 text-center">
{errors.root.message}
</Text>
)}
<Button
title="Verify Email"
onPress={onVerifyPress}
loading={loading}
/>
<TouchableOpacity
onPress={() => setPendingVerification(false)}
className="mt-4"
>
<Text className="text-blue-600 text-center font-medium">
Back to Sign Up
</Text>
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-gray-50">
<ScrollView className="px-6" showsVerticalScrollIndicator={false}>
{/* Logo */}
<View className="items-center pt-16 pb-8">
<View className="w-16 h-16 bg-black rounded-2xl items-center justify-center mb-6">
<Ionicons name="home" size={24} color="white" />
</View>
<Text className="text-2xl font-bold text-gray-900 text-center mb-2">
Create Your Account
</Text>
<Text className="text-gray-600 text-center">
Join us and start your real estate journey.
</Text>
</View>
{/* Social Buttons */}
<View className="mb-6">
<SocialButton
provider="apple"
onPress={() => handleSocialSignUp("oauth_apple")}
loading={socialLoading}
/>
<SocialButton
provider="google"
onPress={() => handleSocialSignUp("oauth_google")}
loading={socialLoading}
/>
<SocialButton
provider="github"
onPress={() => handleSocialSignUp("oauth_github")}
loading={socialLoading}
/>
</View>
{/* Divider */}
<View className="flex-row items-center mb-6">
<View className="flex-1 h-px bg-gray-300" />
<Text className="px-4 text-gray-500">Or register with email</Text>
<View className="flex-1 h-px bg-gray-300" />
</View>
{/* Form */}
<View className="mb-6">
<View className="flex-row space-x-3 mb-4">
<View className="flex-1">
<Controller
control={control}
name="firstName"
rules={{ required: "First name is required" }}
render={({ field: { onChange, onBlur, value } }) => (
<Input
label="First Name"
placeholder="John"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.firstName?.message}
/>
)}
/>
</View>
<View className="flex-1">
<Controller
control={control}
name="lastName"
rules={{ required: "Last name is required" }}
render={({ field: { onChange, onBlur, value } }) => (
<Input
label="Last Name"
placeholder="Doe"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.lastName?.message}
/>
)}
/>
</View>
</View>
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
pattern: {
value: /^\S+@\S+$/i,
message: "Invalid email address",
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<Input
label="Email"
placeholder="john@example.com"
keyboardType="email-address"
autoCapitalize="none"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.email?.message}
/>
)}
/>
<Controller
control={control}
name="password"
rules={{
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<Input
label="Password"
placeholder="ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢"
secureTextEntry={!showPassword}
rightIcon={showPassword ? "eye-off-outline" : "eye-outline"}
onRightIconPress={() => setShowPassword(!showPassword)}
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.password?.message}
/>
)}
/>
{errors.root && (
<Text className="text-red-500 text-sm mb-4 text-center">
{errors.root.message}
</Text>
)}
<Button
title="Create Account"
onPress={handleSubmit(onSubmit)}
loading={loading}
/>
</View>
{/* Sign In Link */}
<View className="flex-row justify-center mb-8">
<Text className="text-gray-600">Already have an account? </Text>
<TouchableOpacity onPress={onSignIn}>
<Text className="text-blue-600 font-medium">Sign In</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
}
screens/ProfileScreen.tsx
import { Ionicons } from "@expo/vector-icons";
import { useAuth, useUser } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
Alert,
Image,
SafeAreaView,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
interface ProfileFormData {
firstName: string;
lastName: string;
}
export default function ProfileScreen() {
const { signOut } = useAuth();
const { user, isLoaded } = useUser();
const router = useRouter();
const [editMode, setEditMode] = useState(false);
const [loading, setLoading] = useState(false);
const {
control,
handleSubmit,
formState: { errors },
reset,
} = useForm<ProfileFormData>({
defaultValues: {
firstName: user?.firstName || "",
lastName: user?.lastName || "",
},
});
const handleSignOut = () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try {
await signOut();
router.replace("/sign-in");
} catch (error) {
console.error("Sign out error:", error);
}
},
},
]);
};
const onSubmit = async (data: ProfileFormData) => {
if (!user) return;
setLoading(true);
try {
await user.update({
firstName: data.firstName,
lastName: data.lastName,
});
setEditMode(false);
Alert.alert("Success", "Profile updated successfully!");
} catch (error: any) {
console.error("Update profile error:", error);
Alert.alert("Error", "Failed to update profile. Please try again.");
} finally {
setLoading(false);
}
};
const getInitials = () => {
if (!user) return "";
const first = user.firstName?.[0] || "";
const last = user.lastName?.[0] || "";
return `${first}${last}`.toUpperCase();
};
if (!isLoaded) {
return (
<SafeAreaView className="flex-1 bg-gray-50 items-center justify-center">
<Text className="text-gray-600">Loading...</Text>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-gray-50">
<ScrollView className="px-6" showsVerticalScrollIndicator={false}>
{/* Header */}
<View className="items-center pt-16 pb-8">
{user?.imageUrl ? (
<Image
source={{ uri: user.imageUrl }}
className="w-24 h-24 rounded-full border-4 border-white shadow-lg"
/>
) : (
<View className="w-24 h-24 rounded-full bg-black items-center justify-center border-4 border-white shadow-lg">
<Text className="text-white text-2xl font-bold">
{getInitials()}
</Text>
</View>
)}
<Text className="text-2xl font-bold text-gray-900 mt-4">
{user?.fullName || "User"}
</Text>
<Text className="text-gray-600 text-base">
{user?.primaryEmailAddress?.emailAddress}
</Text>
</View>
{/* Profile Form */}
<View className="bg-white rounded-2xl p-6 mb-6 shadow-sm">
<View className="flex-row items-center justify-between mb-6">
<Text className="text-xl font-bold text-gray-900">
Profile Information
</Text>
<TouchableOpacity
onPress={() => {
if (editMode) {
setEditMode(false);
reset({
firstName: user?.firstName || "",
lastName: user?.lastName || "",
});
} else {
setEditMode(true);
}
}}
className="p-2"
>
<Ionicons
name={editMode ? "close" : "pencil"}
size={20}
color="#374151"
/>
</TouchableOpacity>
</View>
{editMode ? (
<View>
<View className="flex-row space-x-3 mb-4">
<View className="flex-1">
<Controller
control={control}
name="firstName"
rules={{ required: "First name is required" }}
render={({ field: { onChange, onBlur, value } }) => (
<Input
label="First Name"
placeholder="Enter first name"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.firstName?.message}
/>
)}
/>
</View>
<View className="flex-1">
<Controller
control={control}
name="lastName"
rules={{ required: "Last name is required" }}
render={({ field: { onChange, onBlur, value } }) => (
<Input
label="Last Name"
placeholder="Enter last name"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.lastName?.message}
/>
)}
/>
</View>
</View>
<Button
title="Save Changes"
onPress={handleSubmit(onSubmit)}
loading={loading}
/>
</View>
) : (
<View className="space-y-4">
<View>
<Text className="text-gray-600 text-sm mb-1">Full Name</Text>
<Text className="text-gray-900 text-base font-medium">
{user?.fullName || "Not provided"}
</Text>
</View>
<View>
<Text className="text-gray-600 text-sm mb-1">Email</Text>
<Text className="text-gray-900 text-base font-medium">
{user?.primaryEmailAddress?.emailAddress || "Not provided"}
</Text>
</View>
<View>
<Text className="text-gray-600 text-sm mb-1">Member Since</Text>
<Text className="text-gray-900 text-base font-medium">
{user?.createdAt
? new Date(user.createdAt).toLocaleDateString()
: "N/A"}
</Text>
</View>
</View>
)}
</View>
{/* Connected Accounts */}
{user?.externalAccounts && user.externalAccounts.length > 0 && (
<View className="bg-white rounded-2xl p-6 mb-6 shadow-sm">
<Text className="text-xl font-bold text-gray-900 mb-4">
Connected Accounts
</Text>
<View className="space-y-3">
{user.externalAccounts.map((account, index) => {
const providerString = account.provider?.toString() || "";
const isGoogle = providerString.includes("google");
const isGithub = providerString.includes("github");
const isApple = providerString.includes("apple");
return (
<View
key={index}
className="flex-row items-center justify-between py-3 border-b border-gray-100 last:border-b-0"
>
<View className="flex-row items-center">
<Ionicons
name={
isGoogle
? "logo-google"
: isGithub
? "logo-github"
: isApple
? "logo-apple"
: "link-outline"
}
size={20}
color={
isGoogle
? "#4285F4"
: isGithub
? "#333"
: isApple
? "#000"
: "#6b7280"
}
/>
<View className="ml-3">
<Text className="text-gray-900 font-medium">
{isGoogle
? "Google"
: isGithub
? "GitHub"
: isApple
? "Apple"
: providerString}
</Text>
<Text className="text-gray-500 text-sm">
{account.emailAddress || "Connected"}
</Text>
</View>
</View>
<View className="w-3 h-3 bg-green-500 rounded-full" />
</View>
);
})}
</View>
</View>
)}
{/* Settings */}
<View className="bg-white rounded-2xl p-6 mb-6 shadow-sm">
<Text className="text-xl font-bold text-gray-900 mb-4">Settings</Text>
<View className="space-y-4">
<TouchableOpacity className="flex-row items-center justify-between py-3">
<View className="flex-row items-center">
<Ionicons
name="notifications-outline"
size={20}
color="#6b7280"
/>
<Text className="text-gray-900 font-medium ml-3">
Notifications
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#6b7280" />
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center justify-between py-3">
<View className="flex-row items-center">
<Ionicons name="shield-outline" size={20} color="#6b7280" />
<Text className="text-gray-900 font-medium ml-3">
Privacy & Security
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#6b7280" />
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center justify-between py-3">
<View className="flex-row items-center">
<Ionicons
name="help-circle-outline"
size={20}
color="#6b7280"
/>
<Text className="text-gray-900 font-medium ml-3">
Help & Support
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#6b7280" />
</TouchableOpacity>
</View>
</View>
{/* Sign Out */}
<View className="bg-white rounded-2xl p-6 mb-8 shadow-sm">
<Button title="Sign Out" variant="outline" onPress={handleSignOut} />
</View>
</ScrollView>
</SafeAreaView>
);
}
š Step 5: Create App Pages
app/sign-in.tsx
import { useRouter } from "expo-router";
import SignInScreen from "@/screens/SignInScreen";
export default function SignInPage() {
const router = useRouter();
return <SignInScreen onSignUp={() => router.push("/sign-up")} />;
}
app/sign-up.tsx
import { useRouter } from "expo-router";
import SignUpScreen from "@/screens/SignUpScreen";
export default function SignUpPage() {
const router = useRouter();
return <SignUpScreen onSignIn={() => router.push("/sign-in")} />;
}
app/(tabs)/profile.tsx (Profile Tab)
import ProfileScreen from "@/screens/ProfileScreen";
export default function ProfileTab() {
return <ProfileScreen />;
}
š Congratulations! You now have a fully functional authentication system with:
ā Beautiful, modern UI matching the design ā Email/password authentication ā Social authentication (Google, GitHub, Apple) ā Email verification ā Profile management ā Secure token storage ā Form validation with React Hook Form ā TypeScript support
Your app is ready for production! š
Subscribe to My Latest Guides
All the latest Guides and tutorials, straight from the team.