How to create authenticated routes with the new Expo SDK 51 using Expo Router

How to create authenticated routes with the new Expo SDK 51 using Expo Router

In this blog post, we will be covering “how to create authenticated routes” with the new Expo SDK 51. This guide is meant to be straight forward for our fellow engineers and it is recommended that you follow along with either an existing expo project or create a new project.

We will skip the introduction of how to set up an expo project and jump straight to explaining what you need to do in order to create simple, yet powerful authenticated routes within your application. As a starter, make sure that your expo project version is “expo”: “~51.0.28”.

Once you have the correct expo version, your folder should look like the image below. The image below is of an internal company project I am working on, so don’t be thrown off by all the other folders but if you were to look at your expo folder structure and the image below, you’ll notice some similarities such as the “app” folder and the “providers” folder. I am not sure if the “providers” folder is created upon creating a new expo project, if not, then please create a “providers” folder.

Folder structure

We are mainly concerned with two folders in our expo project, the “app” folder and the “providers” → “Auth” folder. Ignore all the other folders in the above-posted image unless I specifically mention it in here. These two folders will allow us to create authenticated routes.

Our “app” folder contains “index.tsx” and “_layout.tsx”.

index.tsxtsx
1import { View, StyleSheet } from "react-native";
2import { StatusBar } from "expo-status-bar";
3import Onboarding from "@/components/Onboarding/Index";
4
5export default function Index() {
6 return (
7   <View style={styles.container}>
8     <StatusBar style="dark" />
9     <Onboarding />
10   </View>
11 );
12}
13
14const styles = StyleSheet.create({
15 container: {
16   flex: 1,
17 },
18});

Our “index.tsx” file looks like the image above. When users open up the app, the first thing I present to them is the “onboarding” component. You can change that to whatever you want your users to see first when they first open up the app and they are not logged in. Whether it is the “login” screen or the “sign up” screen, you make that decision.

_layouttsx
1import { Slot, SplashScreen, Stack, useRouter, useSegments } from "expo-router";
2import { ToastProvider } from "@/providers/useToast";
3import { useFonts } from "expo-font";
4import { useContext, useEffect } from "react";
5import { SessionProvider, useSession } from "@/providers/Auth/AuthProvider";
6import { GestureHandlerRootView } from "react-native-gesture-handler";
7import { OnboardingProvider } from "@/providers/OnboardingProvider/OnboardingProvider";
8
9import {
10 NetworkContext,
11 NetworkProvider,
12} from "@/providers/NetworkProvider/NetworkProvider";
13
14SplashScreen.preventAutoHideAsync();
15
16const InitialLayout = () => {
17 const [loaded, error] = useFonts({
18   "Inter-Black": require("../assets/fonts/IMPLexSans-Bold.ttf"),
19   "Inter-Black-Medium": require("../assets/fonts/IMPLexSans-Medium.ttf"),
20 });
21
22 const { session, isAuthenticated, onboardingCompleted } = useSession();
23 const { isConnected } = useContext(NetworkContext);
24
25 const segments = useSegments();
26 const router = useRouter();
27
28 useEffect(() => {
29   if (error) throw error;
30 }, [error]);
31
32 useEffect(() => {
33   if (loaded) {
34     SplashScreen.hideAsync();
35   }
36 }, [loaded]);
37
38 useEffect(() => {
39   const authState = segments[0] === "(auth)";
40
41   if (!isConnected) {
42     router.replace("/NoInternet");
43   }
44
45   if (!isAuthenticated && authState) {
46     // Redirect to login if not authenticated and trying to access protected route
47     return router.replace("/(auth)/login");
48   } else if (isAuthenticated === true && !authState) {
49     // Redirect to app if authenticated
50
51     if (onboardingCompleted) {
52       return router.replace("/(auth)/OnboardingFlow/screenOne");
53     } else {
54       return router.replace("/(tabs)/home");
55     }
56   }
57 }, [isAuthenticated, segments, session, isConnected]);
58
59 if (!loaded && !error) {
60   return <Slot />;
61 }
62
63 return (
64   <Stack
65     screenOptions={{
66       headerShown: false,
67       gestureEnabled: false,
68     }}
69   >
70     <Stack.Screen
71       name="index"
72       options={{
73         headerShown: false,
74       }}
75     />
76
77     <Stack.Screen name="login" />
78     <Stack.Screen name="(auth)/(tabs)" />
79   </Stack>
80 );
81};
82
83const RootLayoutNav = () => {
84 return (
85   <GestureHandlerRootView>
86     <NetworkProvider>
87       <ToastProvider>
88         <SessionProvider>
89           <OnboardingProvider>
90             <InitialLayout />
91           </OnboardingProvider>
92         </SessionProvider>
93       </ToastProvider>
94     </NetworkProvider>
95   </GestureHandlerRootView>
96 );
97};
98
99export default RootLayoutNav;

The main file we are concerned with when creating authenticated routes is the _layout.tsx file. Make sure you copy and paste the contents of this file exactly as it is and I will tell you what you can change. The explanation of the useEffect hook which handles the authentication routing is below.

useEffect Hook on line 38:

  • The useEffect hook is used here to run the logic inside when any of the dependencies (isAuthenticated, segments, session, or isConnected) change. It ensures that the appropriate routing and authentication logic is executed based on the current state of the user.

const authState = segments[0] === "(auth)"; on line 39

  • The line const authState = segments[0] === "(auth)"; checks if the current route starts with "(auth)". This indicates that the user is in the authentication part of the app (which includes OnboardingFlow, help, etc.). This checks whether the user is currently trying to access the "(auth)" part in the app.
Folder Auth

Handling Authentication:

  • If the user is not authenticated (!isAuthenticated) and is currently trying to access an "auth" screen (like OnboardingFlow), the user is redirected to the login page.
_layout.tsxtsx
1if (!isAuthenticated && authState) {
2  return router.replace("/login");
3}

On line 48, if the user is authenticated and not in the “auth” group (meaning they’re in the main part of the app either login screen or signup screen), the code checks if onboarding is completed.

  • If the user hasn’t completed onboarding (onboardingCompleted is false), they are redirected to the first onboarding screen "/(auth)/OnboardingFlow/screenOne".
  • If onboarding is completed, they are redirected to the home screen ("/(tabs)/home").
_layout.tsxtsx
1else if (isAuthenticated == true && !authState) {
2  if (onboardingCompleted) {
3    return router.replace("/(auth)/OnboardingFlow/screenOne");
4  } else {
5    return router.replace("/(tabs)/home");
6  }
7}

This means that you can replace the following line to route to any authenticated pages you want

_layout.tsxtsx
1router.replace("/(auth)/OnboardingFlow/screenOne");

or you can simply just immediately route to the home page and avoid the “onboardingCompleted” check which I do in code.

Providers → Auth folder → AuthProviders.tsx

Here is an image of the “AuthProvider.tsx” and “Auth folder” which we will use to write our “sign up” “sign in” and “sign out” function. In the above _layout.tsximage you can see that we are wrapping our app with SessionProvider .

Auth Provider

1import {
2 useContext,
3 createContext,
4 type PropsWithChildren,
5 useState,
6} from "react";
7import { useStorageState } from "./useStorageState";
8import { useToast } from "../useToast";
9
10import auth from "@react-native-firebase/auth";
11import { router } from "expo-router";
12import Constants from "expo-constants";
13
14// Mocking API CALL
15// import axios from "axios";
16import actionProvider from "../actionProvider";
17import axios from "axios";
18
19interface AuthContextType {
20 signIn: (email: string, password: string) => Promise<void>;
21 signOut: () => void;
22 signUp: (email: string, password: string) => Promise<void>;
23 session?: string | null;
24 isLoading: boolean;
25 isAuthenticated: boolean;
26 onboardingCompleted: boolean;
27}
28
29const AuthContext = createContext<AuthContextType>({
30 signIn: async () => {},
31 signOut: () => {},
32 signUp: async () => {},
33 session: null,
34 isLoading: false,
35 isAuthenticated: false,
36 onboardingCompleted: false,
37});
38
39if (Constants.expoConfig?.extra?.environment === "development") {
40 require("../../../mockApical/mockApi"); // Adjust path as necessary
41}
42
43// This hook can be used to access the user info.
44export function useSession() {
45 const value = useContext(AuthContext);
46 if (process.env.NODE_ENV === "production") {
47   if (!value) {
48     throw new Error("useSession must be wrapped in a <SessionProvider />");
49   }
50 }
51
52 return value;
53}
54
55export function SessionProvider({ children }: PropsWithChildren) {
56 const [[isSessionLoading, session], setSession] = useStorageState("session");
57 const [isLoading, setLoading] = useState(false);
58 const [onboardingCompleted, setOnboardingCompleted] = useState(false);
59 const { showToast } = useToast();
60
61 const isAuthenticated = !!session;
62
63 // SignUp function
64// SignUp function continued
65const signUp = async (email: string, password: string) => {
66 if (!email || !password) {
67   showToast("warning", "Missing email and/or password");
68   setLoading(false);
69   return;
70 }
71
72 try {
73   setLoading(true);
74   const user = await auth().createUserWithEmailAndPassword(email, password);
75
76   const token = await user.user.getIdTokenResult();
77
78   console.log("user", user);
79   console.log("token", token.token);
80
81   const data = {
82     email: email,
83     uid: user.user.uid,
84   };
85
86   console.log("data", JSON.stringify(data));
87
88   const endpoint = "signup";
89   const method = "POST";
90
91   const response = await actionProvider.makeRequest(
92     endpoint,
93     method,
94     data,
95     token.token // Pass the token for authorization
96   );
97
98   if (response.success) {
99     showToast("success", "Welcome to Psyvatar! User created successfully.");
100     setSession(token.token); // Set the session with the Firebase token or backend token
101   } else {
102     // Handle if something went wrong at our API side
103     showToast("error", response.message || "Error creating user.");
104   }
105 } catch (error) {
106   switch (error.code) {
107     case "auth/email-already-in-use":
108       showToast(
109         "error",
110         "An account already exists. Please try logging in."
111       );
112       break;
113     case "auth/invalid-email":
114       showToast("error", "The email address is not valid.");
115       break;
116     case "auth/weak-password":
117       showToast("error", "The password is too weak.");
118       break;
119     default:
120       showToast("error", JSON.stringify(error));
121       break;
122   }
123 } finally {
124   setLoading(false); // Set loading state to false after operation completes
125 }
126};
127
128// SignIn function
129const signIn = async (email: string, password: string) => {
130 if (!email || !password) {
131   showToast("warning", "Missing email and/or password");
132   setLoading(false);
133   return;
134 }
135
136 setLoading(true);
137
138 try {
139   // const response = "";
140   // use it like this for get request
141   // if (const response=await actionProvider.makeRequest('user','GET');
142   // use it like this for post or any other
143   // if (const response) = await actionProvider.makeRequest(
144   //   "createUser",
145   //   "POST",
146   //   { avatar_id: me },
147   //   "accesstoken here"
148   // );
149
150   const response = await axios.get("http://localhost:5000/api/v1/user");
151   setOnboardingCompleted(!!response.data.data.completedOnboarding);
152   if (response.data.success) {
153     setSession("jdo9034ujd09uj3c<9gvoc09!...23231234Ssp0rrcif3Gfor");
154     showToast("success", "Successfully signed up!");
155   }
156 } catch (error) {
157   console.log(error);
158   showToast("error", "Failed to sign up");
159 } finally {
160   setLoading(false); // Set loading state to false after operation completes
161 }
162};
163
164  // SignOut function
165const signOut = async () => {
166 setLoading(true);
167
168 try {
169   // await auth().signOut();
170   setSession(null);
171   showToast("success", "Signed out successfully!");
172 } catch (error) {
173   showToast("error", "Failed to sign out");
174 } finally {
175   setLoading(false);
176 }
177};
178
179return (
180 <AuthContext.Provider
181   value={{
182     signIn,
183     signOut,
184     signUp,
185     session,
186     isLoading: isLoading || isSessionLoading,
187     isAuthenticated,
188     onboardingCompleted,
189   }}
190 >
191   {children}
192 </AuthContext.Provider>
193)
194};
195  


AuthContext and Context Provider Setup

Context is used to share the authentication state (logged in, logged out, onboarding, etc.) across different components in your app.

AuthProvider.tsxtsx
1const AuthContext = createContext<AuthContextType>({
2  signIn: async () => {},
3  signOut: () => {},
4  signUp: async () => {},
5  session: null,
6  isLoading: false,
7  isAuthenticated: false,
8  onboardingCompleted: false,
9});

This creates the authentication context and its initial values.

Context Provider

SessionProvidertsx
1export function SessionProvider({ children }: PropsWithChildren) {
2  const [[isSessionLoading, session], setSession] = useStorageState("session");
3  const [isLoading, setLoading] = useState(false);
4  const [onboardingCompleted, setOnboardingCompleted] = useState(false);
  • The SessionProvider wraps your app, giving access to authentication states like session, isLoading, and onboardingCompleted.

Authentication Functions: Sign Up, Sign In, Sign Out

  • These functions handle the core logic for user sign-up, login, and logout processes.

Sign Up Function:

  • Firebase’s auth().createUserWithEmailAndPassword() creates the user account.
  • After user creation, you obtain a token (user.user.getIdTokenResult()), which can be passed to your backend API to create a corresponding user account in your database.
  • Errors are handled and appropriate messages are shown to the user.

1const signUp = async (email: string, password: string) => {
2  if (!email && !password) {
3    showToast("warning", "Missing email and/or password");
4    setLoading(false);
5    return;
6  }
7
8  try {
9    setLoading(true);
10    const user = await auth().createUserWithEmailAndPassword(email, password);
11    const token = await user.user.getIdTokenResult();
12
13    const data = { email: email, uid: user.user.uid };
14    const endpoint = "signup";
15    const response = await actionProvider.makeRequest(endpoint, "POST", data, token.token);
16
17    if (response?.success) {
18      showToast("success", "Welcome! User created successfully.");
19      setSession(token.token);
20    } else {
21      showToast("error", response?.message || "Error creating user.");
22    }
23  } catch (error) {
24    handleSignUpError(error); // e.g., "auth/email-already-in-use"
25  } finally {
26    setLoading(false);
27  }
28};

Sign In Function:

  • Uses Firebase’s auth().signInWithEmailAndPassword() to sign in the user.
  • The session token is retrieved, and the user’s state is set accordingly.

1const signIn = async (email: string, password: string) => {
2  if (!email && !password) {
3    showToast("warning", "Missing email and/or password");
4    setLoading(false);
5    return;
6  }
7  setLoading(true);
8
9  try {
10    const response = await axios.get("http://localhost:5000/api/v1/user");
11    setOnboardingCompleted(!!response.data.data.completedQuestion);
12    if (response.data.success) {
13      setSession("your-session-token");
14      showToast("success", "Successfully signed in!");
15    }
16  } catch (error) {
17    console.log(error);
18    showToast("error", "Failed to sign in");
19  } finally {
20    setLoading(false);
21  }
22};

Sign Out Function:

  • Clears the session by setting setSession(null).
  • Optionally calls auth().signOut() to remove Firebase authentication
1const signOut = async () => {
2  setLoading(true);
3
4  try {
5    await auth().signOut();
6    setSession(null);
7    showToast("success", "Signed out successfully!");
8  } catch (error) {
9    showToast("error", "Failed to sign out");
10  } finally {
11    setLoading(false);
12  }
13};

Exposed Values in Context

  • This is where the power of context lies: all the essential authentication state and functions are passed through the value prop, making them available throughout the app.
1return (
2    <AuthContext.Provider
3      value={{
4        signIn,
5        signOut,
6        signUp,
7        session,
8        isLoading: isLoading || isSessionLoading,
9        isAuthenticated,
10        onboardingCompleted,
11      }}
12    >
13      {children}
14    </AuthContext.Provider>
15  );

Providers → Auth folder → useStorageState.tsx

useStorageState is a custom hook designed to manage storage in mobile. It helps persist the user’s session across app restarts, which is critical for keeping users logged in after closing the app.

When a user signs in, their session token (or authentication token) is generated. This token needs to be stored securely and retrieved later when the app is reopened. useStorageState handles this.

Here is the code. You can remove the “web” checking part.

useStorageState.tstsx
1import * as SecureStore from "expo-secure-store";
2import * as React from "react";
3import { Platform } from "react-native";
4
5type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];
6
7function useAsyncState<T>(
8  initialValue: [boolean, T | null] = [true, null]
9): UseStateHook<T> {
10  return React.useReducer(
11    (
12      state: [boolean, T | null],
13      action: T | null = null
14    ): [boolean, T | null] => [false, action],
15    initialValue
16  ) as UseStateHook<T>;
17}
18
19export async function setStorageItemAsync(key: string, value: string | null) {
20  if (Platform.OS === "web") {
21    try {
22      if (value === null) {
23        localStorage.removeItem(key);
24      } else {
25        localStorage.setItem(key, value);
26      }
27    } catch (e) {
28      console.error("Local storage is unavailable:", e);
29    }
30  } else {
31    if (value == null) {
32      await SecureStore.deleteItemAsync(key);
33    } else {
34      await SecureStore.setItemAsync(key, value);
35    }
36  }
37}
38
39export function useStorageState(key: any): UseStateHook<string> {
40  const [state, setState] = useAsyncState<string>();
41
42  React.useEffect(() => {
43    if (Platform.OS === "web") {
44      try {
45        if (typeof localStorage !== "undefined") {
46          setState(localStorage.getItem(key));
47        }
48      } catch (e) {
49        console.error("Local storage is unavailable:", e);
50      }
51    } else {
52      SecureStore.getItemAsync(key).then((value) => {
53        setState(value);
54      });
55    }
56  }, [key]);
57
58  const setValue = React.useCallback(
59    (value: string | null) => {
60      setState(value);
61      setStorageItemAsync(key, value);
62    },
63    [key]
64  );
65
66  return [state, setValue];
67}

useStorageState leverages Expo SecureStore to store and retrieve sensitive data, such as a user’s session token, securely. Secure storage is critical on mobile to protect user data from un-authorized access, especially when dealing with authentication tokens.

Conclusion

By using SessionProvider, expo-router, and hooks like useSegments, we've built a flexible routing system that ensures only authenticated users can access protected areas of our app. The key is using context to share authentication state and routing logic that redirects users to the correct screens based on their authentication and onboarding status.

This setup provides a robust way to manage routes and authentication in Expo, ensuring a secure and user-friendly flow for mobile apps.

Drag