DEV Community

Cover image for Expo Router V2 - Authentication Flow with Appwrite
Aaron K Saunders
Aaron K Saunders

Posted on

Expo Router V2 - Authentication Flow with Appwrite

This blog post is a complimentary resource to support the video on Expo Router v2

In the video, I walk you through the code for an authentication flow using Expo Router and Appwrite.

I explain how to set up the environment variables, install the router, and incorporate Appwrite as the authentication provider. I also demonstrate how to create login and signup pages, manage authentication state, and handle navigation.

The video provides a step-by-step guide with code examples and explanations. Check it out to learn how to build a secure authentication flow for your Expo app!

Create your Appwrite Project

Appwrite is a backend platform for developing Web, Mobile, and Flutter applications. Built with the open source community and optimized for developer experience in the coding languages you love.

Go to https://cloud.appwrite.io/ to get started

Create Account

Image description

Click Create Project Button

Image description

Enter Project Name

Image description

Under "Add Platform", Click "Web App"

Image description

Provide a Name For Your App

Be sure to enter "cloud.appwrite.io" as your domain

Image description

Install The Appwrite SDK

Image description

All Done

Be sure to copy project id, you will need it for the environment file

Image description

Create Your Expo SDK 49 Project + Expo Router v2

npx create-expo-app@latest --template tabs@49
Enter fullscreen mode Exit fullscreen mode

This command will create the default app with the latest version of expo sdk and expo router. We will be adding an authentication flow to the application and integrating Appwrite Account Creation and Login.

Add Appwrite Library To Project

This code will set up the Appwrite client and provides a convenient interface to interact with the Appwrite server in a react native application.

First you need to create the .env file to hold the Appwrite Project Id

EXPO_PUBLIC_APPWRITE_PROJECT_ID=[your project id]
Enter fullscreen mode Exit fullscreen mode

Then we need to add the code for the library

// /app/lib/appwrite-service.ts
//---- (1) ----
import {
  Account,
  Client,
  ID,
} from "appwrite";

// ---- (2) ----
const client = new Client();
client
  .setEndpoint("https://cloud.appwrite.io/v1")
  .setProject(process.env.EXPO_PUBLIC_APPWRITE_PROJECT_ID as string);

// ---- (3) ----
export const appwrite = {
  client,
  account: new Account(client),
  ID
};
Enter fullscreen mode Exit fullscreen mode

1) Importing the required dependencies
2) Creating a new Appwrite client instance and Configuring the client. This is where we using the value from the .env file
3) Exporting the Appwrite client and related objects; we need the account object for the authentication and account creation functionality. The ID object generates unique identifiers that are utilized when creating the new user account

Add Auth ProviderBased On Expo Router Documentation

I won't spend a lot of time explaining what the Provider is and how React Context works, you can click here for additional information on React Context API

This is the link to the original source code for auth context.

I will cover the changes made to support Typescript and the integration of the appwrite-service library.

This code sets up an authentication context and provider in a React application, provides authentication-related functions, and allows other components to consume the authentication context using the useAuth hook. It handles user authentication, route protection, and authentication-related API interactions with the Appwrite service.

// /app/context/auth.ts
// ---- (1) ----
import { useRootNavigation, useRouter, useSegments } from "expo-router";
import React, { useContext, useEffect, useState } from "react";
import { appwrite } from "../lib/appwrite-service";
import { Models } from "appwrite";

// ---- (2) ----
// Define the AuthContextValue interface
interface SignInResponse {
  data: Models.User<Models.Preferences> | undefined;
  error: Error | undefined;
}

interface SignOutResponse {
  error: any | undefined;
  data: {} | undefined;
}

interface AuthContextValue {
  signIn: (e: string, p: string) => Promise<SignInResponse>;
  signUp: (e: string, p: string, n: string) => Promise<SignInResponse>;
  signOut: () => Promise<SignOutResponse>;
  user: Models.User<Models.Preferences> | null;
  authInitialized: boolean;
}

// Define the Provider component
interface ProviderProps {
  children: React.ReactNode;
}

// ---- (3) ----
// Create the AuthContext
const AuthContext = React.createContext<AuthContextValue | undefined>(
  undefined
);

// ---- (4) ----
export function Provider(props: ProviderProps) {

// ---- (5) ----
  const [user, setAuth] =
    React.useState<Models.User<Models.Preferences> | null>(null);
  const [authInitialized, setAuthInitialized] = React.useState<boolean>(false);

  // This hook will protect the route access based on user authentication.
  // ---- (6) ----
  const useProtectedRoute = (user: Models.User<Models.Preferences> | null) => {
    const segments = useSegments();
    const router = useRouter();

    // checking that navigation is all good;
    // ---- (7) ----
    const [isNavigationReady, setNavigationReady] = useState(false);
    const rootNavigation = useRootNavigation();

    // ---- (8) ----
    useEffect(() => {
      const unsubscribe = rootNavigation?.addListener("state", (event) => {
        setNavigationReady(true);
      });
      return function cleanup() {
        if (unsubscribe) {
          unsubscribe();
        }
      };
    }, [rootNavigation]);

    // ---- (9) ----
    React.useEffect(() => {
      if (!isNavigationReady) {
        return;
      }

      const inAuthGroup = segments[0] === "(auth)";

      if (!authInitialized) return;

      if (
        // If the user is not signed in and the initial segment is not anything in the auth group.
        !user &&
        !inAuthGroup
      ) {
        // Redirect to the sign-in page.
        router.push("/sign-in");
      } else if (user && inAuthGroup) {
        // Redirect away from the sign-in page.
        router.push("/");
      }
    }, [user, segments, authInitialized, isNavigationReady]);
  };

  // ---- (10) ----
  useEffect(() => {
    (async () => {
      try {
        const user = await appwrite.account.get();
        console.log(user);
        setAuth(user);
      } catch (error) {
        console.log("error", error);
        setAuth(null);
      }

      setAuthInitialized(true);
      console.log("initialize ", user);
    })();
  }, []);

  /**
   *
   * @returns
   */
  // ---- (11) ----
  const logout = async (): Promise<SignOutResponse> => {
    try {
      const response = await appwrite.account.deleteSession("current");
      return { error: undefined, data: response };
    } catch (error) {
      return { error, data: undefined };
    } finally {
      setAuth(null);
    }
  };

  /**
   *
   * @param email
   * @param password
   * @returns
   */
  // ---- (12) ----
  const login = async (
    email: string,
    password: string
  ): Promise<SignInResponse> => {
    try {
      console.log(email, password);
      const response = await appwrite.account.createEmailSession(
        email,
        password
      );

      const user = await appwrite.account.get();
      setAuth(user);
      return { data: user, error: undefined };
    } catch (error) {
      setAuth(null);
      return { error: error as Error, data: undefined };
    }
  };

  /**
   * 
   * @param email 
   * @param password 
   * @param username 
   * @returns 
   */
  // ---- (13) ----
  const createAcount = async (
    email: string,
    password: string,
    username: string
  ): Promise<SignInResponse> => {
    try {
      console.log(email, password, username);

      // create the user
      await appwrite.account.create(
        appwrite.ID.unique(),
        email,
        password,
        username
      );

      // create the session by logging in
      await appwrite.account.createEmailSession(email, password);

      // get Account information for the user
      const user = await appwrite.account.get();
      setAuth(user);
      return { data: user, error: undefined };
    } catch (error) {
      setAuth(null);
      return { error: error as Error, data: undefined };
    }
  };

  useProtectedRoute(user);

  // ---- (14) ----
  return (
    <AuthContext.Provider
      value={{
        signIn: login,
        signOut: logout,
        signUp: createAcount,
        user,
        authInitialized,
      }}
    >
      {props.children}
    </AuthContext.Provider>
  );
}

// Define the useAuth hook
// ---- (15) ----
export const useAuth = () => {
  const authContext = useContext(AuthContext);

  if (!authContext) {
    throw new Error("useAuth must be used within an AuthContextProvider");
  }

  return authContext;
};

Enter fullscreen mode Exit fullscreen mode

1) Imports
2) Interfaces for defining the shape of the data returned by the context and responses from the API calls that are utilized in the provider and exposed to the application. The api calls are all setup to return data and error.
3) The AuthContext is created using React.createContext, and the initial value is set to undefined. This context will hold the authentication-related functions and values to be shared with other components.
4)The Provider component is exported. It serves as the provider for the authentication context and wraps its children with the AuthContext.Provider component.
5) Set state variables for the Provider, we keep track of the user with user and we set the variable using setAuth. We use authInitialized to let us know when the application has completed it's check for an existing session that hasn't expired
6) useProtectedRouter is hook called to properly redirect the application to the sign-in route if there is no valid session.
7) local state variables for the hook. isNavigationReady is a check to make sure the navigation is all set up before we attempt to route anywhere in the application
8) useEffect to set up the listener for the rootNavigation state
9) useEffect to route user to proper app location based on what segment the route is in and what the user state variable is set to. This is not called unless authInitialized and isNavigationReady
10) useEffect call when the component is mounted to see if there is a valid user session, We use the appwrite API appwrite.account.get() and set user state with setAuth
11) use Appwrite API call appwrite.account.deleteSession to log the user out
12) use Appwrite API call appwrite.account. createEmailSession to log the user out
13) createAccount is a bit more detailed, you need to first create the user account using appwrite.account.create(), the log the user in using the appwrite.account.createEmailSession api call and then finally get the user information with appwrite.account.get() and set user state with setAuth
14) The Provider component returns the AuthContext.Provider component, which wraps the props.children. It provides the authentication-related values and functions as the context value.
15) The useAuth hook is exported, which allows other components to access the authentication context and retrieve the authentication-related values and functions.

Controlling Navigation Stack In _layout.tsx

This code represents the root layout of a mobile application using React Native and Expo. It sets up the navigation, themes, fonts, and authentication context for the application.

In the code below, the important part is how the code renders the application's content wrapped in the authentication Provider. We need to wrap it in the Provider so we have access to the useAuth hook in the RootLayoutNav

// /app/_layout.tsx
export default function RootLayout() {
  const [loaded, error] = useFonts({
    SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
    ...FontAwesome.font,
  });

  // Expo Router uses Error Boundaries to catch errors in the navigation tree.
  useEffect(() => {
    if (error) throw error;
  }, [error]);

  useEffect(() => {
    if (loaded) {
      SplashScreen.hideAsync();
    }
  }, [loaded]);

  if (!loaded) {
    return null;
  }

  return (
    <Provider>
      <RootLayoutNav />
    </Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code below we use the useAuth hook to check if the authentication has been initialized and if a user is authenticated. If the authentication is not initialized or there is no user, it returns null to render nothing. Otherwise, it renders the application's content wrapped in the ThemeProvider and sets up two screens in a Stack navigator: "(tabs)" and "modal".

// /app/_layout.tsx
function RootLayoutNav() {
  const colorScheme = useColorScheme();
  const { authInitialized, user } = useAuth();

  if (!authInitialized && !user) return null;
  return (
    <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
      <Stack screenOptions={{ headerShown: false }}>
        <Stack.Screen name="(tabs)" />
        <Stack.Screen name="modal" options={{ presentation: "modal" }} />
      </Stack>
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

The (auth) Segment

The folder named (auth) is where we place the sign-in and sign-up screens. Since we are using file based routing we just need to place the files in the folder and expo router does the rest for us.

Sign In Page

// /app/(auth)/sign-in
import {
  Text,
  TextInput,
  View,
  StyleSheet,
  TouchableOpacity,
} from "react-native";
import { useAuth } from "../context/auth";
import { Stack, useRouter } from "expo-router";
import { useRef } from "react";

export default function SignIn() {
  const { signIn } = useAuth();
  const router = useRouter();

  const emailRef = useRef("");
  const passwordRef = useRef("");
  return (
    <>
      <Stack.Screen options={{ title: "sign up", headerShown: false }} />
      <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
        <View>
          <Text style={styles.label}>Email</Text>
          <TextInput
            placeholder="email"
            autoCapitalize="none"
            nativeID="email"
            onChangeText={(text) => {
              emailRef.current = text;
            }}
            style={styles.textInput}
          />
        </View>
        <View>
          <Text style={styles.label}>Password</Text>
          <TextInput
            placeholder="password"
            secureTextEntry={true}
            nativeID="password"
            onChangeText={(text) => {
              passwordRef.current = text;
            }}
            style={styles.textInput}
          />
        </View>

        <TouchableOpacity
          onPress={async () => {
            const { data, error } = await signIn(
              emailRef.current,
              passwordRef.current
            );
            if (data) {
              router.replace("/");
            } else {
              console.log(error);
              // Alert.alert("Login Error", resp.error?.message);
            }
          }}
          style={styles.button}
        >
          <Text style={styles.buttonText}>Login</Text>
        </TouchableOpacity>
        <View style={{ marginTop: 32 }}>
          <Text
            style={{ fontWeight: "500" }}
            onPress={() => router.push("/sign-up")}
          >
            Click Here To Create A New Account
          </Text>
        </View>
      </View>
    </>
  );
}


const styles = StyleSheet.create({
  label: {
    marginBottom: 4,
    color: "#455fff",
  },
  textInput: {
    width: 250,
    borderWidth: 1,
    borderRadius: 4,
    borderColor: "#455fff",
    paddingHorizontal: 8,
    paddingVertical: 4,
    marginBottom: 16,
  },
  button: {
    backgroundColor: "blue",
    padding: 10,
    width: 250,
    borderRadius: 5,
    marginTop: 16,
  },
  buttonText: {
    color: "white",
    textAlign: "center",
    fontSize: 16,
  },
});

Enter fullscreen mode Exit fullscreen mode

Nothing special happening in this file other than importing useAuth so we have access to the signIn function from the AuthContext.

Sign Up Page

// /app/(auth)/sign-up
import {
  Text,
  View,
  StyleSheet,
  TextInput,
  TouchableOpacity,
} from "react-native";
import { useAuth } from "../context/auth";
import { Stack, useRouter } from "expo-router";
import { useRef } from "react";

export default function SignUp() {
  const { signUp } = useAuth();
  const router = useRouter();

  const emailRef = useRef("");
  const passwordRef = useRef("");
  const userNameRef = useRef("");

  return (
    <>
      <Stack.Screen options={{ title: "sign up", headerShown: false }} />
      <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
        <View>
          <Text style={styles.label}>UserName</Text>
          <TextInput
            placeholder="Username"
            autoCapitalize="none"
            nativeID="userName"
            onChangeText={(text) => {
              userNameRef.current = text;
            }}
            style={styles.textInput}
          />
        </View>
        <View>
          <Text style={styles.label}>Email</Text>
          <TextInput
            placeholder="email"
            autoCapitalize="none"
            nativeID="email"
            onChangeText={(text) => {
              emailRef.current = text;
            }}
            style={styles.textInput}
          />
        </View>
        <View>
          <Text style={styles.label}>Password</Text>
          <TextInput
            placeholder="password"
            secureTextEntry={true}
            nativeID="password"
            onChangeText={(text) => {
              passwordRef.current = text;
            }}
            style={styles.textInput}
          />
        </View>

        <TouchableOpacity
          onPress={async () => {
            const { data, error } = await signUp(
              emailRef.current,
              passwordRef.current,
              userNameRef.current
            );
            if (data) {
              router.replace("/");
            } else {
              console.log(error);
              // Alert.alert("Login Error", resp.error?.message);
            }
          }}
          style={styles.button}
        >
          <Text style={styles.buttonText}>Create Account</Text>
        </TouchableOpacity>

        <View style={{ marginTop: 32 }}>
          <Text
            style={{ fontWeight: "500" }}
            onPress={() => router.replace("/sign-in")}
          >
            Click Here To Return To Sign In Page
          </Text>
        </View>
      </View>
    </>
  );
}

const styles = StyleSheet.create({
  label: {
    marginBottom: 4,
    color: "#455fff",
  },
  textInput: {
    width: 250,
    borderWidth: 1,
    borderRadius: 4,
    borderColor: "#455fff",
    paddingHorizontal: 8,
    paddingVertical: 4,
    marginBottom: 16,
  },
  button: {
    backgroundColor: "blue",
    padding: 10,
    width: 250,
    borderRadius: 5,
    marginTop: 16,
  },
  buttonText: {
    color: "white",
    textAlign: "center",
    fontSize: 16,
  },
});

Enter fullscreen mode Exit fullscreen mode

Nothing special happening in this file other than importing useAuth so we have access to the signUp function from the AuthContext.

Handling Sign Out

We modified the first Tab Page content to include a logout button. In the page we once again import useAuth to get access to the signOut function.

import { StyleSheet } from 'react-native';

import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
import { useAuth } from '../context/auth';

export default function TabOneScreen() {
  const { signOut, user } = useAuth();
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Tab One</Text>
      <View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
      <EditScreenInfo path="app/(tabs)/index.tsx" />
      <Text onPress={() => signOut()}>Sign Out - {user?.email}</Text>

    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  title: {
    fontSize: 20,
    fontWeight: 'bold',
  },
  separator: {
    marginVertical: 30,
    height: 1,
    width: '80%',
  },
});
Enter fullscreen mode Exit fullscreen mode

Wrap Up

This pattern can be used with any account management solution. I used Appwrite only becuase it was something different and I like to mix things up a bit. You could have easily integrated Firebase or Supabase into the AuthContext and the application will work perfectly for you.

I hope you found this helpful, please check out the video and the rest of the content on my YouTube Channel.

Related Links

Related Videos

Full Source Code

GitHub logo aaronksaunders / expo-router-v2-authflow-appwrite

Expo Router - File Based Routing for React Native, tabs template with auth flow using context api

expo-router-v2-authflow-appwrite

Expo Router - File Based Routing for React Native, tabs template with auth flow using context API


Click Here If You Are Looking For Supabase Source Code - SUPABASE AUTH FLOW


This is the source code from the video on Expo Router v2

In the video, I walk you through the code for an authentication flow using Expo Router and Appwrite.

I explain how to set up the environment variables, install the router, and incorporate Apprite as the authentication provider. I also demonstrate how to create login and signup pages, manage authentication state, and handle navigation.

The video provides a step-by-step guide with code examples and explanations. Check it out to learn how to build a secure authentication flow for your Expo app!

Video

https://www.youtube.com/watch?v=Ud_GWxu1_Xg

Social Media

Top comments (12)

Collapse
 
davidhenry profile image
David Henry

I think I'm missing something as when I have a user it falls through to a [...missing] page.

Here I think is my issue as there is a missing edge case in:

if (
// If the user is not signed in and the initial segment is not anything in the auth group.
!user &&
!inAuthGroup
) {
// Redirect to the sign-in page.
router.push("/sign-in");
} else if (user && inAuthGroup) {
// Redirect away from the sign-in page.
router.push("/");
}

Potentially missing... if (user && !inAuthGroup) { ...

Everything else works perfectly. Just trying to figure out where I went wrong.

Collapse
 
aaronksaunders profile image
Aaron K Saunders

do you have a simple project somewhere i could take a look at?

Collapse
 
davidhenry profile image
David Henry

Yes, sure - where do I share it?

Thread Thread
 
aaronksaunders profile image
Aaron K Saunders

You can DM me on Twitter if you donโ€™t want to post link to project here

Thread Thread
 
davidhenry profile image
David Henry

Wouldn't let me DM so dropped you an email hope you don't mind. Have a great day man!

Thread Thread
 
aaronksaunders profile image
Aaron K Saunders

i dont see an email?

Thread Thread
 
davidhenry profile image
David Henry • Edited

HYG, github.com/davidlintin/expo-router... here are all the Authentication files let me know if you need more.

Thread Thread
 
aaronksaunders profile image
Aaron K Saunders

this is not a project :-(

Thread Thread
 
davidhenry profile image
David Henry • Edited

Ah sorry, finally figured out GitHub :) [(github.com/davidlintin/expo-router...]

Collapse
 
vins13pattar profile image
Vinod S Pattar

Saved my day!!! Post upgrading to SDK 49 and router v2 I was struggling with authentication issues!! Thank you for the wonderful solution.

Collapse
 
olalexy1 profile image
Olalekan Ajayi • Edited

Thank you for this tutotial. I tried this solution and replaced it with firebase-auth but I am getting an unmatched route on app load, Been on this for some days, Pls can you help take a look?

Collapse
 
aaronksaunders profile image
Aaron K Saunders

do you have a sample project i can take a look at?