Back to blog
How to Build a Fullstack Mobile App with Expo
You’ve got a great idea for a mobile app but there’s just one problem: you don’t know how to build mobile apps! Sure, you can make something on the web, but you want the distribution of app stores, not to mention the speed and performance of a native app. Building an app with multiple languages and workflows is also difficult. Thankfully, we have tools like Expo, a React Native platform that lets you build apps for iOS, Android, and the web.
Ok, you’ve got Expo picked out to build a mobile app, but there’s one other small issue: you need a backend. You need to store user data and make sure that user requests are being authenticated. Believe it or not, that too has gotten easier with tools like Clerk to handle auth and Pinata to handle files + key-value stores. In this tutorial, we’ll show you how to build both the server and client for a full stack mobile app with auth that lets users upload and view photos.
Setup
To build this app, we’re going to have both a server and a client to make our app secure. The overall structure will look something like this:
- Server
- Client
- Expo: Core client stack that can deploy to iOS, Android, and Web
- Clerk: Authenticate requests to our server
Before we start writing some code, we need to setup our external providers.
Pinata
Getting setup with Pinata is a breeze and only takes a few minutes. Just create a free account and then follow these steps to get your API key and Gateway URL!
Clerk
For our auth, we’ll be using Clerk as it provides a nice SDK for Expo and middleware for Hono that we can use with our server. Just sign up for an account then make a new application. Give it a name like Photos Gallery, and select the Email and Github options for auth.
Once you’ve created the application, you’ll want to click on the Configure tab, then navigate down to API Keys. From there, copy the public Perishable key, as well as the Secret key and keep them somewhere safe. Don’t worry about the formatting of the variables in the top right snippet because we’ll cover that soon.
Server
With the setup out of the way, we can start building! To begin, we’ll create a Bun + Hono server to handle and authenticate our requests. If you have not already, go ahead and install Bun on your machine. Then, run the following command in your terminal to make a new Hono project.
bun create hono expo-server
Once it’s setup, you can cd
into it and install our dependencies
cd expo-server
bun add @clerk/backend @hono/clerk-auth pinata
The next step will be adding our API keys and environment variables. Make a file in the root of the project called .env
with the following variables, be sure to fill them with your own keys!
PINATA_JWT= # Your Pinata API Key JWT
GATEWAY_URL= # Your Pinata Gateway url, in the format example.mypinata.cloud
CLERK_PUBLISHABLE_KEY= # Clerk public Perishable key
CLERK_SECRET_KEY= # Clerk secret key
The primary reason we need a server in the first place is that our PINATA_JWT
and CLERK_SECRET_KEY
are private and should never be shared with anyone. Since Expo / React Native is a client side only stack, these variables would be exposed if we tried to use them there. This way everything stays secured and only logged in users through our Clerk auth can make requests to our server.
For the rest of our code we’ll just stay in the src/index.ts
file, and it’s all relatively simple! To start we’ll import some dependencies like Hono, Clerk, and Pinata, as well as setup an interface for future types.
import { Hono } from "hono";
import { cors } from "hono/cors";
import { clerkMiddleware, getAuth } from "@hono/clerk-auth";
import { PinataSDK } from "pinata";
const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT,
pinataGateway: process.env.GATEWAY_URL,
});
interface UrlObject {
url: string;
}
Now, let’s setup the app
that will allow us to do endpoints. Something important we do here as well is add our cors()
and clerkMiddleware()
with app.use()
. This adds the secret sauce we need to intercept and authenticate requests.
const app = new Hono();
app.use("/*", cors());
app.use("*", clerkMiddleware());
app.get("/", (c) => {
return c.text("Welcome!");
});
// Endpoints will go here
export default app;
For our first endpoint, let’s make a POST /files
that will let an authenticated user upload a file to Pinata.
app.post("/files", async (c) => {
const body = await c.req.parseBody();
const file = body.file as File;
const name = body.name as string;
const auth = getAuth(c);
if (!auth?.userId) {
return c.json(
{
message: "You are not logged in.",
},
401,
);
}
if (!file || !name) {
return c.json({ error: "Invalid upload" }, 400);
}
try {
const upload = await pinata.upload.file(file).addMetadata({
name: name,
keyvalues: {
user: auth.userId,
},
});
return c.json({ data: upload }, 200);
} catch (error) {
console.error("Error uploading file:", error);
return c.json({ error: "Failed to upload file" }, 500);
}
});
In here, we’ll expect a multipart/form-data
request that we’ll render with c.req.parseBody()
. From there, we’ll use our getAuth
hook by passing in the request context, then do a check if there is a userId
for the request. If not, we’ll throw a 401. We’ll also throw a 400 request if there isn’t a file or name attached. Then we can go ahead and upload the file to Pinata. Take note on how we’re using the addMetadata
method on our upload which allows us to have keyvalues
storing the userId
— you’ll see why soon!
With our upload finished, we need another endpoint to retrieve the files that a given user has uploaded previously uploaded. To do this, let’s use GET /files
for the same path.
app.get("/files", async (c) => {
const auth = getAuth(c);
if (!auth?.userId) {
return c.json(
{
message: "You are not logged in.",
},
401,
);
}
try {
const files = await pinata.files
.list()
.metadata({
user: auth.userId,
})
const urls: UrlObject[] = [];
for (const file of files.files) {
const url = await pinata.gateways
.createSignedURL({
cid: file.cid,
expires: 3000,
})
.optimizeImage({
width: 700,
});
urls.push({ url: url });
}
return c.json(urls, 200);
} catch (error) {
console.error("Error fetching files:", error);
return c.json({ error: "Failed to fetch files" }, 500);
}
});
Just like the last endpoint, we’ll do a quick check to make sure our user is logged in. From there, we can list our files from our Pinata account, and with just a few extra lines of code, filter the results by userId
from our authenticated request. Since all file uploads to Pinata’s File API are private by default, we need to give our user access to them. To handle this we’ll use the createSignedURL
method in the Files SDK which makes it easy for us to not only put a time limit on the file URLs, but also since we’re only using images we can go ahead and also use optimizeImage
on the URLs to make them lightweight on the client!
With just those two endpoints, our server is complete! You can start up the dev server with npm run dev
and then the server URL will be http://localhost:3000
. When using this in conjunction with our Expo app, it’s important to keep the following in mind:
- Make sure the server is running (obvious one!)
- If you want to text the app on a separate device, then you’ll need to either deploy the app to a service like Railway, or use a tunnel like Tailscale.
Let’s keep moving and start up our client code!
Client
Expo provide a really smooth experience for building and deploying cross platform React Native apps. While this tutorial doesn’t require it, I would highly recommend signing up for an account with Expo to take advantage of some of their tools like Expo Go to test the apps on native devices.
To create our Expo project, simply run the following command in your terminal:
npx create-expo-app@latest expo-client
Once it has finished setting up the project, you can cd
into it and install some of our dependencies.
cd expo-client
npm install @clerk/clerk-expo react-native-otp-entry
Expo also features some unique packages in their ecosystem which we can install with npx expo
, so we’ll do that for the following two packages:
npx expo install expo-image-picker expo-secure-store
With all our packages installed, we’ll create a .env.local
file in the root of the project with the following variables:
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=
EXPO_PUBLIC_SERVER_URL=
Remember to keep in mind how you’ll be providing your server URL while building the client! For simplicity, we’ll just run it locally by using http://localhost:3000
. With our initial scaffolding up, we’ll cover the next pieces of the app in several chunks, starting with our Auth.
Auth
We’ll be generally following the Clerk guide for Expo as well as their example repo. The first thing we need to do is create a cache.ts
file in the root of the project to handle different cache scenarios based on the platform being used.
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import type { TokenCache } from "@clerk/clerk-expo/dist/cache";
const createTokenCache = (): TokenCache => {
return {
getToken: async (key: string) => {
try {
const item = await SecureStore.getItemAsync(key);
if (item) {
console.log(`${key} was used 🔐 \\n`);
} else {
console.log(`No values stored under key: ${key}`);
}
return item;
} catch (error) {
console.error("secure store get item error: ", error);
await SecureStore.deleteItemAsync(key);
return null;
}
},
saveToken: (key: string, token: string) => {
return SecureStore.setItemAsync(key, token);
},
};
};
// SecureStore is not supported on the web
// <https://github.com/expo/expo/issues/7744#issuecomment-611093485>
export const tokenCache =
Platform.OS !== "web" ? createTokenCache() : undefined;
Next we’ll need to wrap our app with the <ClerkProvider>
and pass in our tokenCache
as well as our perishableKey
. All of this will happen in our main _layout.tsx
in the app
directory.
import { useFonts } from "expo-font";
import { Slot } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { ClerkLoaded, ClerkProvider } from "@clerk/clerk-expo";
import { tokenCache } from "@/cache";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!;
if (!publishableKey || publishableKey.length === 0) {
throw new Error(
"Missing Publishable Key. Please set EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY in your .env",
);
}
return (
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded>
<Slot />
</ClerkLoaded>
</ClerkProvider>
);
}
When we created the Expo app with npx create-expo-app
, it included some template code, including a directory called (tabs)
. Go ahead and delete that entire folder and make a new one called (auth)
inside the app
directory. Then we’ll add in the following _layout.tsx
file inside of it:
import { Redirect, Stack } from "expo-router";
import { useAuth } from "@clerk/clerk-expo";
export default function UnAuthenticatedLayout() {
const { isSignedIn } = useAuth();
if (isSignedIn) {
return <Redirect href={"/"} />;
}
return <Stack />;
}
This will do a simple check if the user is logged in or not and redirect accordingly. Now we need the actual sign in and sign up pages in our app. Start by making app/(auth)/sign-in.tsx
and putting in the following code:
import { useState } from "react";
import {
View,
TouchableOpacity,
Text,
StyleSheet,
TextInput,
ActivityIndicator,
} from "react-native";
import { useSignIn, isClerkAPIResponseError } from "@clerk/clerk-expo";
import type {
ClerkAPIError,
EmailCodeFactor,
SignInFirstFactor,
} from "@clerk/types";
import { Link, useRouter } from "expo-router";
import OAuthButtons from "@/components/OAuthButtons";
import { OtpInput } from "react-native-otp-entry";
export default function Page() {
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [showOTPForm, setShowOTPForm] = useState(false);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<ClerkAPIError[]>([]);
const { signIn, setActive, isLoaded } = useSignIn();
const router = useRouter();
async function handleEmailSignIn() {
if (!isLoaded) return;
setLoading(true);
setErrors([]);
try {
// Start the sign-in process using the email method
const { supportedFirstFactors } = await signIn.create({
identifier: email,
});
// Filter the returned array to find the 'email' entry
const isEmailCodeFactor = (
factor: SignInFirstFactor,
): factor is EmailCodeFactor => {
return factor.strategy === "email_code";
};
const emailCodeFactor = supportedFirstFactors?.find(isEmailCodeFactor);
if (emailCodeFactor) {
// Grab the emailAddressId
const { emailAddressId } = emailCodeFactor;
// Send the OTP code to the user
await signIn.prepareFirstFactor({
strategy: "email_code",
emailAddressId,
});
// Set showOTPForm to true to display second form and capture the OTP code
setShowOTPForm(true);
}
} catch (err) {
if (isClerkAPIResponseError(err)) {
setErrors(err.errors);
}
console.error(JSON.stringify(err, null, 2));
}
setLoading(false);
}
async function handleVerification() {
if (!isLoaded) return;
setLoading(true);
setErrors([]);
try {
// Use the code provided by the user and attempt verification
const completeSignIn = await signIn.attemptFirstFactor({
strategy: "email_code",
code,
});
// If verification was completed, set the session to active
// and redirect the user
if (completeSignIn.status === "complete") {
await setActive({ session: completeSignIn.createdSessionId });
router.replace("/photos");
} else {
// If the status is not complete, check why. User may need to
// complete further steps.
console.error(JSON.stringify(completeSignIn, null, 2));
}
} catch (err: any) {
// See <https://clerk.com/docs/custom-flows/error-handling>
// for more info on error handling
if (isClerkAPIResponseError(err)) {
setErrors(err.errors);
}
console.error(JSON.stringify(err, null, 2));
}
setLoading(false);
}
if (showOTPForm) {
return (
<View style={styles.container}>
<Text style={styles.title}>Check your email</Text>
<Text style={styles.subtitle}>to continue to your app</Text>
<OtpInput
focusColor="#a0a0a0"
theme={{
containerStyle: { marginBottom: 15 },
}}
numberOfDigits={6}
onTextChange={setCode}
/>
{errors.length > 0 && (
<View style={styles.errorContainer}>
{errors.map((error, index) => (
<Text key={index} style={styles.errorMessage}>
• {error.longMessage}
</Text>
))}
</View>
)}
<TouchableOpacity
style={styles.continueButton}
onPress={handleVerification}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.continueButtonText}>Continue ▸</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backButton}
onPress={() => setShowOTPForm(false)}
>
<Text style={styles.footerTextLink}>Back</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Sign in to Your App</Text>
<Text style={styles.subtitle}>
Welcome back! Please sign in to continue
</Text>
<OAuthButtons />
<Text style={styles.orSeparator}>or</Text>
<Text style={styles.label}>Email address</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
/>
{errors.length > 0 && (
<View style={styles.errorContainer}>
{errors.map((error, index) => (
<Text key={index} style={styles.errorMessage}>
• {error.longMessage}
</Text>
))}
</View>
)}
<TouchableOpacity
style={styles.continueButton}
onPress={handleEmailSignIn}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.continueButtonText}>Continue ▸</Text>
)}
</TouchableOpacity>
<View style={styles.footerTextContainer}>
<Text style={styles.footerText}>
Don't' have an account?{" "}
<Link style={styles.footerTextLink} href="/sign-up">
Sign up
</Link>
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
width: "100%",
alignSelf: "center",
},
title: {
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
},
subtitle: {
fontSize: 16,
color: "gray",
textAlign: "center",
marginBottom: 20,
},
orSeparator: {
textAlign: "center",
marginVertical: 15,
color: "gray",
},
label: {
fontSize: 16,
marginBottom: 5,
},
input: {
borderWidth: 1,
borderColor: "#e0e0e0",
borderRadius: 8,
padding: 12,
fontSize: 16,
marginBottom: 15,
},
errorContainer: {
marginBottom: 15,
},
errorMessage: {
color: "red",
fontSize: 14,
marginBottom: 5,
},
continueButton: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
borderRadius: 8,
alignItems: "center",
},
continueButtonText: {
color: "white",
fontSize: 16,
fontWeight: "bold",
},
backButton: {
alignItems: "center",
marginTop: 15,
},
footerTextContainer: {
marginTop: 20,
alignItems: "center",
},
footerText: {
fontSize: 16,
color: "gray",
},
footerTextLink: {
color: "black",
fontWeight: "bold",
},
});
This in and of itself is a lot of boilerplate code that will handle sign in with Email, Password, one time codes, and OAuth using GitHub! We do have a component we’ll make in a moment, but the important pieces to note here are how Clerk will work with different sign in methods and will act accordingly. If the user is authenticated, then they’ll be redirected to our yet-to-be-built page /photos
.
If the user isn’t signed up, of course we’ll need a page for that too. Make a file called sign-up.tsx
in the same folder with the following code:
import { useState } from "react";
import {
View,
TouchableOpacity,
Text,
StyleSheet,
TextInput,
ActivityIndicator,
} from "react-native";
import { useSignUp, isClerkAPIResponseError } from "@clerk/clerk-expo";
import type { ClerkAPIError } from "@clerk/types";
import { Link, useRouter } from "expo-router";
import { OtpInput } from "react-native-otp-entry";
import OAuthButtons from "@/components/OAuthButtons";
export default function Page() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showOTPForm, setShowOTPForm] = useState(false);
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<ClerkAPIError[]>([]);
const { signUp, setActive, isLoaded } = useSignUp();
const router = useRouter();
async function handleSignUp() {
if (!isLoaded) return;
setLoading(true);
setErrors([]);
try {
// Start the sign-up process using the email and password method
await signUp.create({
emailAddress: email,
password,
});
// Start the verification - a OTP code will be sent to the email
await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
// Set showOTPForm to true to display second form and capture the OTP code
setShowOTPForm(true);
} catch (err) {
// See <https://clerk.com/docs/custom-flows/error-handling>
// for more info on error handling
console.error(JSON.stringify(err, null, 2));
}
setLoading(false);
}
async function handleVerification() {
if (!isLoaded) return;
setLoading(true);
try {
// Use the code provided by the user and attempt verification
const signInAttempt = await signUp.attemptEmailAddressVerification({
code,
});
// If verification was completed, set the session to active
// and redirect the user
if (signInAttempt.status === "complete") {
await setActive({ session: signInAttempt.createdSessionId });
router.replace("/photos");
} else {
// If the status is not complete, check why. User may need to
// complete further steps.
console.error(JSON.stringify(signInAttempt, null, 2));
}
} catch (err: any) {
// See <https://clerk.com/docs/custom-flows/error-handling>
// for more info on error handling
if (isClerkAPIResponseError(err)) {
setErrors(err.errors);
}
console.error(JSON.stringify(err, null, 2));
}
setLoading(false);
}
if (showOTPForm) {
return (
<View style={styles.container}>
<Text style={styles.title}>Check your email</Text>
<Text style={styles.subtitle}>to continue to your app</Text>
<OtpInput
focusColor="#a0a0a0"
theme={{
containerStyle: { marginBottom: 15 },
}}
numberOfDigits={6}
onTextChange={setCode}
/>
<TouchableOpacity
style={styles.continueButton}
onPress={handleVerification}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.continueButtonText}>Continue ▸</Text>
)}
</TouchableOpacity>
{errors.length > 0 && (
<View style={styles.errorContainer}>
{errors.map((error, index) => (
<Text key={index} style={styles.errorMessage}>
• {error.longMessage}
</Text>
))}
</View>
)}
<TouchableOpacity
style={styles.backButton}
onPress={() => setShowOTPForm(false)}
>
<Text style={styles.footerTextLink}>Back</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Create your account</Text>
<Text style={styles.subtitle}>
Welcome! Please fill in the details to get started.
</Text>
<OAuthButtons />
<Text style={styles.orSeparator}>or</Text>
<Text style={styles.label}>Email address</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
/>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry={true}
/>
{errors.length > 0 && (
<View style={styles.errorContainer}>
{errors.map((error, index) => (
<Text key={index} style={styles.errorMessage}>
• {error.longMessage}
</Text>
))}
</View>
)}
<TouchableOpacity style={styles.continueButton} onPress={handleSignUp}>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.continueButtonText}>Continue ▸</Text>
)}
</TouchableOpacity>
<View style={styles.footerTextContainer}>
<Text style={styles.footerText}>
Already have an account?{" "}
<Link style={styles.footerTextLink} href="/sign-in">
Sign in
</Link>
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
width: "100%",
alignSelf: "center",
},
title: {
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
},
subtitle: {
fontSize: 16,
color: "gray",
textAlign: "center",
marginBottom: 20,
},
orSeparator: {
textAlign: "center",
marginVertical: 15,
color: "gray",
},
label: {
fontSize: 16,
marginBottom: 5,
},
input: {
borderWidth: 1,
borderColor: "#e0e0e0",
borderRadius: 8,
padding: 12,
fontSize: 16,
marginBottom: 15,
},
errorContainer: {
marginBottom: 15,
},
errorMessage: {
color: "red",
fontSize: 14,
marginBottom: 5,
},
continueButton: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
borderRadius: 8,
alignItems: "center",
},
continueButtonText: {
color: "white",
fontSize: 16,
fontWeight: "bold",
},
backButton: {
alignItems: "center",
marginTop: 15,
},
footerTextContainer: {
marginTop: 20,
alignItems: "center",
},
footerText: {
fontSize: 16,
color: "gray",
},
footerTextLink: {
color: "black",
fontWeight: "bold",
},
});
This page has a similar flow, except it can handle sign ups + the sign in action once they have signed up. Something we used in both of those pages is the OAuthButtons
which we haven’t made yet, so let’s do that now. Make a new file in the components
folder called OAuthButtons.tsx
and put in the code below:
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
import { FontAwesome } from "@expo/vector-icons";
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
import { useOAuth } from "@clerk/clerk-expo";
interface SSOButtonProps {
icon: string;
text: string;
onPress: () => void;
}
const SSOButton: React.FC<SSOButtonProps> = ({ icon, text, onPress }) => (
<TouchableOpacity style={styles.ssoButton} onPress={onPress}>
<FontAwesome
name={icon as any}
size={20}
color="black"
style={styles.icon}
/>
<Text style={styles.ssoButtonText}>{text}</Text>
</TouchableOpacity>
);
export default function OAuthButtons() {
const { startOAuthFlow } = useOAuth({
strategy: "oauth_github",
});
const router = useRouter();
async function handleSSO() {
try {
const { createdSessionId, setActive } = await startOAuthFlow({
redirectUrl: Linking.createURL("/photos", { scheme: "myapp" }),
});
if (!setActive) {
return Error("Invalid Set Active");
}
if (createdSessionId) {
setActive({ session: createdSessionId });
router.push("/photos");
}
} catch (err) {
console.error(JSON.stringify(err, null, 2));
}
}
return (
<View>
<SSOButton icon="github" text="GitHub" onPress={handleSSO} />
</View>
);
}
const styles = StyleSheet.create({
ssoButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: 12,
borderRadius: 8,
marginBottom: 12,
backgroundColor: "white",
borderWidth: 1,
borderColor: "#e0e0e0",
},
ssoButtonText: {
color: "black",
fontSize: 16,
fontWeight: "bold",
},
icon: {
marginRight: 10,
},
});
This one is really simple and creates an SSO sign in flow using GitHub. If you configure your app in Clerk to handle other external providers, then you can add them here as well. With that button complete, we now have our auth set and ready to go.
Photos
Now we can start building the actual functionality of our app! To start, we’ll make another directory called (home)
inside the app
folder and we’ll add the standard _layout.tsx
inside of it.
import { Stack } from "expo-router";
export default function HomeLayout() {
return <Stack />;
}
Easy! Now we’ll add an index.tsx
page which will act as the app landing.
import React from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { Link } from "expo-router";
import { useAuth } from "@clerk/clerk-expo";
export default function Page() {
const { isSignedIn } = useAuth();
return (
<View style={styles.container}>
<Text style={styles.title}>Pinata Photo Expo</Text>
<Text style={styles.subtitle}>Choose an action to continue</Text>
{!isSignedIn && (
<>
<Link href="/sign-in" asChild>
<TouchableOpacity style={styles.button}>
<Text style={styles.buttonText}>Sign in page</Text>
</TouchableOpacity>
</Link>
<Link href="/sign-up" asChild>
<TouchableOpacity style={styles.button}>
<Text style={styles.buttonText}>Sign up page</Text>
</TouchableOpacity>
</Link>
</>
)}
{isSignedIn ? (
<>
<Link href="/profile" asChild>
<TouchableOpacity style={styles.button}>
<Text style={styles.buttonText}>Profile</Text>
</TouchableOpacity>
</Link>
<Link href="/photos" asChild>
<TouchableOpacity style={styles.button}>
<Text style={styles.buttonText}>Photos</Text>
</TouchableOpacity>
</Link>
</>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
width: "100%",
alignSelf: "center",
},
title: {
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
},
subtitle: {
fontSize: 16,
color: "gray",
textAlign: "center",
marginBottom: 20,
},
button: {
padding: 12,
borderRadius: 8,
marginBottom: 12,
backgroundColor: "white",
borderWidth: 1,
borderColor: "#e0e0e0",
},
buttonText: {
color: "black",
fontSize: 16,
fontWeight: "bold",
},
});
In here, we do a check if the user is logged in. If not, we provide routes for them to either sign up or sign into their account. Once they are signed in, we’ll have buttons where they can visit either their /profile
or the /photos
. Let’s knock out that profile.tsx
page real quick with the code below.
import React from "react";
import { useClerk, useUser } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import {
StyleSheet,
Text,
View,
TouchableOpacity,
Image,
ScrollView,
} from "react-native";
export default function Page() {
const { user } = useUser();
const clerk = useClerk();
const router = useRouter();
async function handleSignOut() {
await clerk.signOut();
router.replace("/");
}
if (user === undefined) {
return <Text>Loading...</Text>;
}
if (user === null) {
return <Text>Not signed in</Text>;
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Image source={{ uri: user.imageUrl }} style={styles.profileImage} />
<Text style={styles.name}>{user.fullName || "User"}</Text>
<Text style={styles.email}>
{user.primaryEmailAddress?.emailAddress}
</Text>
</View>
<View style={styles.infoSection}>
<InfoItem label="Username" value={user.username || "Not set"} />
<InfoItem label="ID" value={user.id} />
<InfoItem
label="Created"
value={new Date(user.createdAt!).toLocaleDateString()}
/>
<InfoItem
label="Last Updated"
value={new Date(user.updatedAt!).toLocaleDateString()}
/>
</View>
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
<Text style={styles.signOutButtonText}>Sign Out</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.push("/")}
>
<Text style={styles.backButtonText}>Back</Text>
</TouchableOpacity>
</ScrollView>
);
}
const InfoItem = ({ label, value }: { label: string; value: string }) => (
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{label}:</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
);
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
header: {
alignItems: "center",
padding: 20,
backgroundColor: "#fff",
},
profileImage: {
width: 100,
height: 100,
borderRadius: 50,
marginBottom: 10,
},
name: {
fontSize: 22,
fontWeight: "bold",
marginBottom: 5,
},
email: {
fontSize: 16,
color: "gray",
},
infoSection: {
backgroundColor: "#fff",
marginTop: 20,
padding: 20,
},
infoItem: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 10,
},
infoLabel: {
fontWeight: "bold",
},
infoValue: {
color: "gray",
},
signOutButton: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 15,
borderRadius: 8,
margin: 20,
alignItems: "center",
},
signOutButtonText: {
color: "white",
fontSize: 16,
fontWeight: "bold",
},
backButton: {
alignItems: "center",
margin: 20,
padding: 12,
borderRadius: 8,
backgroundColor: "white",
borderWidth: 1,
borderColor: "#e0e0e0",
},
backButtonText: {
color: "black",
fontWeight: "bold",
},
});
This page is a simple overview of the account info through Clerk which can help you see what you’re working with in the rest of the app, as well as provide a place where the user can sign out. In a real application, it might make more sense to turn some of this into a drop down menu to make it simpler for people to log out and make the /photos
page the main focal point. With that said, let’s now make the photos.tsx
page with this code:
import React from "react";
import { useClerk, useUser } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import {
StyleSheet,
Text,
View,
TouchableOpacity,
Image,
ScrollView,
} from "react-native";
import { Photos } from "@/components/Photos";
export default function Page() {
const { user } = useUser();
const clerk = useClerk();
const router = useRouter();
async function handleSignOut() {
await clerk.signOut();
router.replace("/");
}
if (user === undefined) {
return <Text>Loading...</Text>;
}
if (user === null) {
return <Text>Not signed in</Text>;
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Image source={{ uri: user.imageUrl }} style={styles.profileImage} />
<Text style={styles.name}>{user.fullName || "User"}</Text>
</View>
<View>
<Photos />
</View>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.push("/")}
>
<Text style={styles.backButtonText}>Back</Text>
</TouchableOpacity>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
header: {
alignItems: "center",
padding: 20,
backgroundColor: "#fff",
},
profileImage: {
width: 100,
height: 100,
borderRadius: 50,
marginBottom: 10,
},
name: {
fontSize: 22,
fontWeight: "bold",
marginBottom: 5,
},
email: {
fontSize: 16,
color: "gray",
},
infoSection: {
backgroundColor: "#fff",
marginTop: 20,
padding: 20,
},
infoItem: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 10,
},
infoLabel: {
fontWeight: "bold",
},
infoValue: {
color: "gray",
},
signOutButton: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 15,
borderRadius: 8,
margin: 20,
alignItems: "center",
},
signOutButtonText: {
color: "white",
fontSize: 16,
fontWeight: "bold",
},
backButton: {
alignItems: "center",
margin: 20,
padding: 12,
borderRadius: 8,
backgroundColor: "white",
borderWidth: 1,
borderColor: "#e0e0e0",
},
backButtonText: {
color: "black",
fontWeight: "bold",
},
});
There’s not much going on here other than we display the user’s profile image and name, as well as import the main <Photos>
component where all the magic is happening. Make a new file called Photos.tsx
inside the components
folder and put in the following code:
import { useAuth } from "@clerk/clerk-expo";
import * as ImagePicker from "expo-image-picker";
import React, { useCallback, useEffect, useState } from "react";
import {
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Image,
Platform,
View,
Text,
ScrollView,
RefreshControl,
Dimensions,
} from "react-native";
type FileItem = {
url: string;
};
const screenWidth = Dimensions.get("window").width;
export function Photos() {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const { getToken } = useAuth();
const serverUrl = process.env.EXPO_PUBLIC_SERVER_URL!;
if (!serverUrl || serverUrl.length === 0) {
throw new Error(
"Missing Server URL. Please set EXPO_PUBLIC_CLERK_SERVER_URL in your .env",
);
}
const fetchFiles = async () => {
try {
setLoading(true);
const token = await getToken();
const request = await fetch(`${serverUrl}/files`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!request.ok) {
throw new Error(`Failed to fetch files: ${request.status}`);
}
const data = await request.json();
setFiles(data);
} catch (e) {
console.error("Error fetching files:", e);
setError(e instanceof Error ? e.message : "Failed to load files");
} finally {
setLoading(false);
setRefreshing(false);
}
};
const pickAndUploadImage = async () => {
try {
setError(null);
const permissionResult =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
setError("Permission to access camera roll is required!");
return;
}
const pickerResult = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 1,
});
if (pickerResult.canceled) {
return;
}
setUploading(true);
const { uri } = pickerResult.assets[0];
const formData = new FormData();
if (Platform.OS === "ios") {
const uriParts = uri.split(".");
const fileType = uriParts[uriParts.length - 1];
formData.append("file", {
uri,
name: `upload.${fileType}`,
type: `image/${fileType}`,
} as any);
formData.append("name", "File from Expo");
} else {
const fileData = await fetch(uri);
if (!fileData) {
console.error("Error loading file.", fileData);
return;
}
const blob = await fileData.blob();
formData.append("file", blob);
formData.append("name", "File from Expo");
}
const token = await getToken();
const response = await fetch(`${serverUrl}/files`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
setUploading(false);
await fetchFiles();
} catch (e) {
console.error("Upload error:", e);
setError(
e instanceof Error ? e.message : "An error occurred during upload",
);
}
};
useEffect(() => {
fetchFiles();
}, []);
const onRefresh = useCallback(() => {
setRefreshing(true);
fetchFiles();
}, []);
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<View style={styles.uploadSection}>
<View style={styles.headerContainer}>
<Text style={styles.title}>Upload Image</Text>
<Text style={styles.subtitle}>
Select an image from your device to upload
</Text>
</View>
<TouchableOpacity
style={styles.uploadButton}
onPress={pickAndUploadImage}
disabled={uploading}
>
{uploading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.uploadButtonText}>Choose Image</Text>
)}
</TouchableOpacity>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorMessage}>
<Text>• </Text>
<Text>{error}</Text>
</Text>
</View>
)}
</View>
<View style={styles.filesContainer}>
<Text style={styles.sectionTitle}>Your Files</Text>
{loading && (
<ActivityIndicator size="large" color="rgba(0, 0, 0, 0.8)" />
)}
{error && <Text style={styles.errorMessage}>{error}</Text>}
{files?.map((file) => (
<View key={file.url} style={styles.fileItem}>
<Image
source={{
uri: file.url,
}}
style={styles.image}
/>
</View>
))}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
width: "100%",
},
headerContainer: {
marginBottom: 20,
},
title: {
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
},
subtitle: {
fontSize: 16,
color: "gray",
textAlign: "center",
},
uploadButton: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
borderRadius: 8,
alignItems: "center",
marginBottom: 15,
},
uploadButtonText: {
color: "white",
fontSize: 16,
fontWeight: "bold",
},
errorContainer: {
marginBottom: 15,
},
errorMessage: {
color: "red",
fontSize: 14,
marginBottom: 5,
},
filesContainer: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 15,
},
fileItem: {
marginBottom: 20,
},
image: {
width: screenWidth - 40,
height: screenWidth - 40,
borderRadius: 8,
},
uploadSection: {
marginTop: 20,
},
});
Ok, we’ve got a lot going on here, so let’s break it down piece by piece. The first thing we do is make sure we have our SERVER_URL
so we can upload and retrieve files. Next we have our fetchFiles
function where we’re able to use getToken()
from Clerk to authenticate our requests with our server; that simple. If the request is successful, then we’ll set our files to the returned URLs.
Then we have our pickAndUploadImage
which utilizes our expo image picker. This makes it easy to get access to images on different devices across different platforms as they will generally have different privacy requirements. Something else you’ll want to note here is how we are changing the formData
depending on the platform. With ios
we have to craft the file data differently then web or Android. Once we have the formData
ready we send a request to our server using the same getToken()
from Clerk to authenticate it. After it goes through, we’ll run our previous function fetchFiles()
to refresh the image feed.
After handling our UI to render the images and functionality of picking an image, the app is complete! To try it out, you’ll want to use npm run start
in the terminal and then press w
to open the web view. You’ll need to make sure you have your server running either on local host or through a deployed instance. In the end, you should get something like this!
If you got lost at any point along the way, or just want a reference, the repos for both the server and the client can be found below!
Wrapping Up
If you wanted to take the project further, then I would highly recommend deploying the server to something like Railway so you can test the app in Expo Go, an app you can get for iOS or Android. Experiencing the app as native code on your mobile device is well worth it, especially knowing you built the backend and the front end! Of course, this is just a place to start; from here you could easily add Groups to help manage files better, or perhaps even add likes or comments to photos using key-values. You’d be surprised how much you can do in an app with just a few simple tools, so don’t be afraid to push the boundaries. In any case, we can’t wait to see what you build!