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:
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.
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.
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.
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.
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.
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 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.