Telerik blogs

This series will explore Appwrite, a self-hosted server backend, plus BaaS platform. We’ll dive in with a React invoicing app, showcasing authentication flow, database storage and serverless functions. In Part 2, we’ll create the sign-up/login authentication flow.

Welcome to the second part of the series on “Getting Started with Appwrite.” In Part 1, we handled the setup, which comprised of:

  • Creating a new React app with TailwindCSS using Vite
  • Creating a new Appwrite Project
  • Setting up a database, invoices collection and storage

In this part, we will create an authentication form and integrate it with Appwrite to allow users to create a new account and log in.

Authentication Methods

In the previous part, we configured routes, including auth routes—auth/register and auth/login. We also created empty components for them. Before we add the authentication form, let’s prepare the business logic that will handle the authentication process. We will start by creating a new api file called auth.api.js.

src/api/auth.api.js

import { ID } from "appwrite";
import { account } from "./appwrite.api";
 
export const createAccount = (email, password) => {
  return account.create(ID.unique(), email, password);
};
 
export const login = (email, password) => {
  return account.createEmailSession(email, password);
};
 
export const getCurrentAuthSession = () => {
  return account.get();
};

We have three methods—createAccount, login and getCurrentAuthSession. They use the Appwrite SDK account instance configured in the appwrite.api.js file.

I think the names are quite explanatory, but if you’re wondering why we need getCurrentAuthSession, here’s the reason. When a user visits the app, we need to check if they are already logged in. If they have an active login session, we don’t want to force them to log in again. That’s not a good UX. However, if they are not logged in, we don’t want to allow them to just access any of the invoice pages. Instead, we want to redirect them to the login page instead. The getCurrentAuthSession will be used to check if a user is logged in.

The auth information and user’s details usually need to be accessed in a few different places throughout an application. Therefore, it should be stored in a way that allows any component to access it. Larger applications generally utilize a state management solution such as Zustand or Redux Toolkit to handle global and shareable state. However, we are not building a large app, so let’s put the auth logic at the root of the React app and provide it to the descendants via the Context API.

User Context Provider

Let’s start by creating a context file that will create and export two contexts and functions to consume them.

src/context/user.context.js

import { createContext, useContext } from "react";
 
export const UserContext = createContext({});
export const UserActionsContext = createContext({});
 
export const useUserContext = () => useContext(UserContext);
export const useUserActionsContext = () => useContext(UserActionsContext);

The UserContext will provide information about the user, while UserActionsContext will provide methods, such as login and createAccount. The reason for using separate contexts is performance. Basically, whenever the context’s value changes, all context consumers re-render. Therefore, we provide the user data and methods separately.

Note that providing values through one context often will not result in performance issues, especially if the provided values do not change frequently. However, it’s a good practice to do things in a performant way to avoid slowness caused by accumulating many small problems. If you would like to learn more about this, I wrote an article about performance bottlenecks which covers it in detail.

Next, let’s add the UserContextProvider component. It will utilize the contexts we just created, initialize the user’s auth session and redirect a user if they try to access a non-auth route without being logged in.

src/context/UserContextProvider.jsx

import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { createAccount, getCurrentAuthSession, login } from "../api/auth.api";
import { UserActionsContext, UserContext } from "./user.context";
 
const UserContextProvider = props => {
  const [user, setUser] = useState(null);
  const [isInitialized, setIsInitialized] = useState(false);
  const navigate = useNavigate();
  const location = useLocation();
  const initUserSession = async () => {
    /**
     * Initialize user's auth session. 
     * If there is no session, redirect to the login page.
     */
    try {
      const currentSession = await getCurrentAuthSession();
      if (currentSession) {
        setUser(currentSession);
        if (location.pathname.includes("auth")) {
          navigate("/");
        }
      } else {
        navigate("/auth/login");
      }
    } catch (error) {
      console.error(error);
      navigate("/auth/login");
    }
    setIsInitialized(true);
  };
 
  useEffect(() => {
    /**
     * Initialize user's session if the app was just loaded
     * or if a user was already initialized, determine if a user should be redirected to the login page. 
     */
    if (isInitialized) {
      if (!user && !location.pathname.includes("auth")) {
        navigate("/auth/login");
      }
    } else {
      initUserSession();
    }
  }, [location.pathname]);
 
  const value = useMemo(() => {
    return {
      user,
    };
  }, [user]);
 
  const actions = useMemo(() => {
    return {
      login,
      createAccount,
      setUser,
    };
  }, []);
 
  return (
    <UserContext.Provider value={value}>
      <UserActionsContext.Provider value={actions}>
        {isInitialized ? (
          props.children
        ) : (
          <div className="flex items-center justify-center min-h-screen font-semibold text-indigo-600">
            Loading...
          </div>
        )}
      </UserActionsContext.Provider>
    </UserContext.Provider>
  );
};
 
export default UserContextProvider;

Let’s digest what’s happening in the UserContextProvider component. First, we have useState, useNavigate and useLocation hooks.

const [user, setUser] = useState(null);
const [isInitialized, setIsInitialized] = useState(false);
const navigate = useNavigate();
const location = useLocation();

In the user state, we store the information about the user that is returned by the getCurrentAuthSesssion function. The isInitialized state is used to determine whether the user’s auth state was already initialized or not. The navigate function will be used to redirect the user either to a login or invoices page, depending on their auth status.

Finally, we need access to the location object. It will be used by an auth guard to prevent unauthenticated users from accessing invoice pages. Note that in a larger project it might be better to add auth guards in route definitions, but this approach is sufficient for this tutorial.

After the hooks are executed, we have the initUserSession. This function utilizes the getCurrentAuthSession function from the appwrite.api.js file. If a user is already logged in, we update the user state and navigate to the invoices route if a user tries to access a login or register page.

const initUserSession = async () => {
  /**
   * Initialize user's auth session. 
   * If there is no session, redirect to the login page.
   */
  try {
    const currentSession = await getCurrentAuthSession();
    if (currentSession) {
      setUser(currentSession);
      if (location.pathname.includes("auth")) {
        navigate("/");
      }
    } else {
      navigate("/auth/login");
    }
  } catch (error) {
    console.error(error);
    navigate("/auth/login");
  }
  setIsInitialized(true);
};

If a user is not authenticated, they are redirected to the login page.

Next, we have the useEffect hook. It will run when the component is first rendered and any time the URL pathname changes. The initUserSession function is called if a user’s auth state was not initialized yet. Otherwise, if there is no user data and the user tries to access a non-auth page, they will be redirected to the login page.

useEffect(() => {
  /**
   * Initialize user's session if the app was just loaded
   * or if a user was already initialized, determine if a user should be redirected to the login page. 
   */
  if (isInitialized) {
    if (!user && !location.pathname.includes("auth")) {
      navigate("/auth/login");
    }
  } else {
    initUserSession();
  }
}, [location.pathname]);

Last but not least, we have value and actions variables and JSX returned by the component.

const value = useMemo(() => {
  return {
    user,
  };
}, [user]);
 
const actions = useMemo(() => {
  return {
    login,
    createAccount,
    setUser,
  };
}, []);
 
return (
  <UserContext.Provider value={value}>
    <UserActionsContext.Provider value={actions}>
      {isInitialized ? (
        props.children
      ) : (
        <div className="flex items-center justify-center min-h-screen font-semibold text-indigo-600">
          Loading...
        </div>
      )}
    </UserActionsContext.Provider>
  </UserContext.Provider>
);

The value and actions variables are memoized to ensure the references of objects passed via contexts stay the same if there were no changes. This will help to avoid unnecessarily re-rendering context consumers.

The children are only rendered when the user’s auth state was initialized. Otherwise, a loading text is displayed.

Now, let’s update the App component and wrap its content with the UserContextProvider.

src/App.jsx

import { Outlet } from "react-router-dom";
import "./App.css";
import { Suspense } from "react";
import { Toaster } from "react-hot-toast";
import UserContextProvider from "./context/UserContextProvider";
 
function App() {
  return (
    <UserContextProvider>
      <Suspense loading={<div />}>
        <Outlet />
      </Suspense>
      <Toaster />
    </UserContextProvider>
  );
}
 
export default App;

Besides adding the UserContextProvider, we also include the Toaster component from react-hot-toast. It will be used for error notifications.

Let me just add here that there is a reason why the contexts are created in a separate file and are not placed inside of the UserContextProvider.jsx file. In the development mode, Vite React Plugin uses React Fast Refresh, which lets us edit React components in a running application without losing their state. However, it requires that files with React components must export only a component and nothing else.

Login and Register Forms

Now that we have set up most of the authentication logic, let’s handle the authentication form. First, we will create a custom Input component.

src/components/form/Input.jsx

const Input = props => {
  const { id, label, value, onChange, rootProps, ...inputProps } = props;
  return (
    <div className="flex flex-col w-full gap-1" {...rootProps}>
      {label ? (
        <label htmlFor={id} className="text-sm text-indigo-950/75">
          {label}
        </label>
      ) : null}
      <input
        id={id}
        className="px-4 py-2 rounded-md shadow"
        type="text"
        value={value}
        onChange={event => {
          onChange(event.target.value, event);
        }}
        {...inputProps}
      />
    </div>
  );
};
 
export default Input;

In the auth form, we will have only two input fields—email and password. However, in the next part of this series, we will create an invoice form, which will need many more. That’s why we will use a custom component to display an input with a label.

After the Input component is ready, it’s time for the Auth.jsx file.

src/views/auth/Auth.jsx

import { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import Input from "../../components/form/Input";
import { useUserActionsContext } from "../../context/user.context";
 
const config = {
  login: {
    header: "Login",
    submitButtonText: "Log In",
    toggleAuthModeLink: {
      to: "/auth/register",
      text: "Create a new account",
    },
  },
  register: {
    header: "Create Account",
    submitButtonText: "Register",
    toggleAuthModeLink: {
      to: "/auth/login",
      text: "Already have an account?",
    },
  },
};
 
const Auth = () => {
  const { login, createAccount, setUser } = useUserActionsContext();
  const [form, setForm] = useState({
    email: "",
    password: "",
  });
  const navigate = useNavigate();
  const [error, setError] = useState(null);
  const location = useLocation();
  const isCreateAccountPage = location.pathname.includes("register");
  const { header, submitButtonText, toggleAuthModeLink } =
    config[isCreateAccountPage ? "register" : "login"];
 
  const onFormChange = key => value => {
    setForm(state => ({
      ...state,
      [key]: value,
    }));
  };
 
  const onFormSubmit = async event => {
    event.preventDefault();
    const { email, password } = form;
 
    if (!email) {
      setError("Please enter your email.");
      return;
    }
 
    if (!password) {
      setError("Please enter the password.");
      return;
    }
 
    try {
      if (isCreateAccountPage) {
        await createAccount(email, password);
      }
 
      const loginSession = await login(email, password);
      setUser(loginSession);
 
      navigate("/");
    } catch (error) {
      console.error(error);
      setError(error.messsage);
    }
  };
 
  return (
    <div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400">
      <div className="flex items-center justify-center w-3/4 mx-8 bg-white md:w-1/2 md:min-h-screen md:ml-auto md:mx-0 max-md:rounded-2xl">
        <form className="w-full p-8 md:w-96 md:p-4" onSubmit={onFormSubmit}>
          <h1 className="mb-8 text-2xl font-semibold text-center">{header}</h1>
 
          <div className="flex flex-col items-start gap-3">
            <Input
              label="Email"
              id="email-field"
              className="px-4 py-2 rounded-md shadow"
              type="email"
              value={form.email}
              onChange={onFormChange("email")}
            />
            <Input
              label="Password"
              id="password-field"
              className="px-4 py-2 rounded-md shadow"
              type="password"
              value={form.password}
              onChange={onFormChange("password")}
            />
          </div>
 
          {error ? <p className="block mt-2 text-red-600">{error}</p> : null}
          <button
            className="block w-full h-12 mt-6 text-indigo-100 transition-colors duration-150 bg-indigo-600 rounded-md hover:bg-indigo-800"
            type="submit"
          >
            {submitButtonText}
          </button>
 
          <Link
            className="block mt-6 text-center text-indigo-900 transition-colors duration-150 hover:text-indigo-600"
            to={toggleAuthModeLink.to}
          >
            {toggleAuthModeLink.text}
          </Link>
        </form>
      </div>
    </div>
  );
};
 
export default Auth;

We determine whether we are on the login or register page by checking if the URL includes the word register. The result is stored in the isCreateAccountPage variable, which in turn is used to get information from the config object for the current page. On the login page, users will see the Login header with Log In button text and Create a new account link, while on the register page they will see the Create Account header with Register button text and Already have an account? link. The GIF below shows what the auth pages look like.

Auth Pages

When a user submits the auth form, the onFormSubmit handler is called. We have a very simple validation, which checks if the email and password are present. Normally, it’s a good idea to check if the email provided looks like an actual email and whether the password satisfies specific rules, such as minimum length, characters, etc. If a user is on the create account page, the createAccount function is called. It’s important to note that createAccount doesn’t automatically login a user, so we still need to execute the login method.

const onFormSubmit = async event => {
  event.preventDefault();
  const { email, password } = form;
  
  if (!email) {
    setError("Please enter your email.");
    return;
  }
 
  if (!password) {
    setError("Please enter the password.");
    return;
  }
 
  try {
    if (isCreateAccountPage) {
      await createAccount(email, password);
    }
 
    const loginSession = await login(email, password);
    setUser(loginSession);
 
    navigate("/");
  } catch (error) {
    console.error(error);
    setError(error)
  }
};

Now, you can create a new account. After account creation, you will be redirected to the invoices page, which, at the moment, doesn’t contain much—but we will take care of that in the next part.

Conclusion

We covered how to implement authentication in React using Appwrite. We created a UserContextProvider component and took advantage of the Context API to provide the user data as well as user and auth methods to the rest of the application. We also made sure that a user can’t visit invoice pages without being authenticated. Finally, we created an auth component to handle register and login pages. In the next part, we will build a form to create and update invoices.


Thomas Findlay-2
About the Author

Thomas Findlay

Thomas Findlay is a 5-star rated mentor, full-stack developer, consultant, technical writer and the author of “React - The Road To Enterprise” and “Vue - The Road To Enterprise.” He works with many different technologies such as JavaScript, Vue, React, React Native, Node.js, Python, PHP and more. Thomas has worked with developers and teams from beginner to advanced and helped them build and scale their applications and products. Check out his Codementor page, and you can also find him on Twitter.

Related Posts

Comments

Comments are disabled in preview mode.