React Query is a server state management library. This article will teach you how to implement JWT Authentication and Authorization with React Query, Axios interceptors, Typescript, and React-Hook-Form.

More practice:

Here is what you’ll learn:

  • JWT Authentication and Authorization with React, React Query, and Axios Interceptors
  • How to make HTTP requests with React Query and Axios
  • How to perform form validations with React-Hook-Form and Zod
  • How to protect private routes with a middleware
  • How to refresh an access token with Axios interceptors
  • How to verify the registered user’s email address
React Query, Context API, Axios Interceptors JWT Auth

What and Why React Query?

React Query is often described as a server state management library for React. To be more precise, it eliminates the need to hand-write data fetching, caching, cache invalidation, polling, synchronizing, and updating server state logic in your React applications.

Over the last couple of years, the React community has come to realize that most state management libraries work really well with client states but do not have any built-in tool to manage server states.

Nowadays, almost every web application needs to fetch data from a server in order to display it in the UI. They also need to update the data, send the updates to the server, and keep the data stored on the server in sync with the cached data on the client.

This pattern is made more complicated when the need arises to implement more advanced features used in recent applications:

  • Managing memory and garbage collection of server state
  • Combining multiple requests for the same data into a single request
  • Managing cache lifetimes (knowing when data is “out of date“)
  • Requesting fresh data from the server when the cached data is “out of date
  • Tracking loading state in order to display loading spinners in the UI

At the time of writing this article, there have been a lot of changes in the React ecosystem:

React Query, Typescript, and Axios Overview

We will build a React.js, Material UI, and Typescript client before adding React Query and Axios to implement the JWT Authentication logic.

The registration and login form validation will be done with React-hook-form and Zod.

Here are some screenshots:

– The user lands on the home page and clicks on the “SIGNUP” button to register for an account.

react query axios interceptors jwt authentication home page

-On the registration page, the user provides the required credentials and makes a POST request to the server by clicking the “SIGN UP” button.

react query axios interceptors jwt authentication user registration

-The server then validates the credentials and sends a verification code to the user’s email.

react query axios interceptors jwt authentication signup success

-The user opens the email and clicks on the “Verify Your Account” button.

react query axios interceptors jwt authentication check email

-The user is immediately redirected to the email verification page where the verification code will be pre-filled in the text field.
Next, the user clicks on the “VERIFY EMAIL” button to make a GET request with the code to the server.

react query axios interceptors jwt authentication verify email address

-The server validates the verification code and sends a success message to the frontend application.

The frontend app then receives the success message and redirects the user to the login page where he is required to provide the credentials used for the account registration.

react query axios interceptors jwt authentication email verified

-After the user clicks the log-in button, a POST request will be made by React Query to the server.

react query axios interceptors jwt authentication user logged-in success

-Also, you can check the browser to see the cookies returned by the server after the user is authenticated.

login success jwt cookies

React Query and Axios JWT Authentication Flow

To authenticate a user, we are going to call five routes:

  • React Query Axios POST request api/auth/register – to register the new user
  • React Query Axios GET request api/auth/verifyemail – to verify the user’s email address
  • React Query Axios POST request api/auth/login – to authenticate the registered user.
  • React Query Axios GET request api/auth/logout – to logout the user
  • React Query Axios GET request api/auth/refresh – to retrieve a new access token.

Below is a diagram describing the account registration flow:

User registration flow with email verification

JWT Authentication flow with React Query, Axios, Typescript, and a backend API.

Jwt Authentication flow with React and backend api

You can find a step-by-step implementation of the backend APIs in the following tutorials:

Setup React Query, MUI, Axios, and Typescript Project

First things first, let’s generate a basic React.js application.

Run this command in your terminal to create a React.js boilerplate application in a react-query-axios directory.


yarn create react-app react-query-axios --template typescript
# or
npx create-react-app react-query-axios --template typescript

Install Material UI, React-Hook-Form, and React Router Dom

Run the following command to install Material UI v5, React-Hook-Form, and React-Router-Dom:

Material UI v5


yarn add @mui/material @emotion/react @emotion/styled @mui/lab
# or 
npm install @mui/material @emotion/react @emotion/styled @mui/lab

React-Hook-Form and Zod


yarn add react-hook-form @hookform/resolvers zod
# or 
npm install react-hook-form @hookform/resolvers zod

React-Router-Dom and some other dependencies


yarn add react-toastify react-router-dom react-cookie
# or 
npm install react-toastify react-router-dom react-cookie

Install React Query and Axios

React Query


yarn add @tanstack/react-query @tanstack/react-query-devtools
# or
npm install @tanstack/react-query @tanstack/react-query-devtools

Note: React Query is compatible with React v16.8+ and works great with ReactDOM and React Native.

To configure React Query with React.js, we need to wrap the root app with the QueryClientProvider component and provide it with the QueryClient instance.


import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient();

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <react.strictmode>
    <queryclientprovider client="{queryClient}">
      <app />
      <reactquerydevtools initialisopen="{false}" />
   </queryclientprovider>
  </react.strictmode>
);

Out of the box, React Query considers cached data as stale and the stale queries are prefetched automatically in the background when:

  • New instances of the query mount
  • The window is refocused
  • The network is reconnected.
  • The query is optionally configured with a re-fetch interval.

To change the default behaviors, you can configure your queries both globally and per query using the config object.

You can change the default behaviors globally by passing defaultOptions config to the QueryClient() . For example:


const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      refetchOnmount: false,
      refetchOnReconnect: false,
      retry: 1,
      staleTime: 5 * 1000,
    },
  },
});

  • refetchOnWindowFocus : If "always", the query will always re-fetch in the background on window focus.
  • refetchOnmount : If true, the query will re-fetch on mount if the cached data is stale.
  • refetchOnReconnect : Defaults to true . If true, the query will re-fetch on reconnect if the cached data is stale
  • retry : If set to a number, failed queries will retry until the failed query count reaches that number.
  • staleTime : The time in milliseconds after data becomes stale.

Axios


yarn add axios
# or 
npm install axios

Define the Request and Response Types

After all the configuration above, it’s now time to define the data types with Typescript.

Create and export the request and response interfaces in a src/api/types.ts file.

src/api/types.ts


export interface IUser {
  name: string;
  email: string;
  role: string;
  _id: string;
  id: string;
  createdAt: string;
  updatedAt: string;
  __v: number;
}

export interface GenericResponse {
  status: string;
  message: string;
}

export interface ILoginResponse {
  status: string;
  access_token: string;
}

export interface IUserResponse {
  status: string;
  data: {
    user: IUser;
  };
}

Create the API Services with Axios

Before creating the Axios authentication services, we need to configure Axios to use some default configurations.

Create an src/api/authApi.ts file and add the code below to create the Axios instance.

src/api/authApi.ts


import axios from 'axios';
const BASE_URL = 'http://localhost:8000/api/';

export const authApi = axios.create({
  baseURL: BASE_URL,
  withCredentials: true,
});

authApi.defaults.headers.common['Content-Type'] = 'application/json';
// (...)

Next, let’s define the logic to refresh the access token when it expires.


import axios from 'axios';
import { LoginInput } from '../pages/login.page';
import { RegisterInput } from '../pages/register.page';
import { GenericResponse, ILoginResponse, IUserResponse } from './types';

// Axios instance and default configuration

export const refreshAccessTokenFn = async () => {
  const response = await authApi.get<ILoginResponse>('auth/refresh');
  return response.data;
};

authApi.interceptors.response.use(
  (response) => {
    return response;
  },
  async (error) => {
    const originalRequest = error.config;
    const errMessage = error.response.data.message as string;
    if (errMessage.includes('not logged in') && !originalRequest._retry) {
      originalRequest._retry = true;
      await refreshAccessTokenFn();
      return authApi(originalRequest);
    }
    return Promise.reject(error);
  }
);

On the server, there are many scenarios where I returned a 401 Unauthorized error so it doesn’t make sense to use that for the if condition in the response interceptor.

However, the server will return a 401 status code along with a message indicating that the access token wasn’t included in the request.

So, if the message contains “not logged in” then it means the server did not find the access token in the cookies or the authorization header.

After the Axios response interceptor checks the error message and discovers that it contains “not logged in“, it will immediately evoke the refreshAccessTokenFn() service to refresh the access token.

For security reasons, the access and refresh tokens are stored in HTTPOnly cookies in the browser to prevent hackers from using Javascript to access them.

Note: You need to set withCredentials: true in the Axios config for the browser to include the cookies along with the requests.

Now, let’s add the services to perform the JWT authentication against the API server:

  • refreshAccessTokenFn : Makes a GET request to retrieve a new access token.
  • signUpUserFn : Makes a POST request to register the new user.
  • loginUserFn : Makes a POST request to sign in the registered user.
  • verifyEmailFn : Makes a GET request to verify the user’s email.
  • logoutUserFn : Makes a GET request to log out the user.
  • getMeFn : Makes a GET request to retrieve the authenticated user’s credentials.

src/api/authApi.ts


import axios from 'axios';
import { LoginInput } from '../pages/login.page';
import { RegisterInput } from '../pages/register.page';
import { GenericResponse, ILoginResponse, IUserResponse } from './types';

const BASE_URL = 'http://localhost:8000/api/';

const authApi = axios.create({
  baseURL: BASE_URL,
  withCredentials: true,
});

authApi.defaults.headers.common['Content-Type'] = 'application/json';

export const refreshAccessTokenFn = async () => {
  const response = await authApi.get<ILoginResponse>('auth/refresh');
  return response.data;
};

authApi.interceptors.response.use(
  (response) => {
    return response;
  },
  async (error) => {
    const originalRequest = error.config;
    const errMessage = error.response.data.message as string;
    if (errMessage.includes('not logged in') && !originalRequest._retry) {
      originalRequest._retry = true;
      await refreshAccessTokenFn();
      return authApi(originalRequest);
    }
    return Promise.reject(error);
  }
);

export const signUpUserFn = async (user: RegisterInput) => {
  const response = await authApi.post<GenericResponse>('auth/register', user);
  return response.data;
};

export const loginUserFn = async (user: LoginInput) => {
  const response = await authApi.post<ILoginResponse>('auth/login', user);
  return response.data;
};

export const verifyEmailFn = async (verificationCode: string) => {
  const response = await authApi.get<GenericResponse>(
    `auth/verifyemail/${verificationCode}`
  );
  return response.data;
};

export const logoutUserFn = async () => {
  const response = await authApi.get<GenericResponse>('auth/logout');
  return response.data;
};

export const getMeFn = async () => {
  const response = await authApi.get<IUserResponse>('users/me');
  return response.data;
};

Create a React Context API to Manage Client State

To manage the client state, I decided to use React Context API because I felt using Redux Toolkit will be an overkill for this project.

The new React Context API allows us to avoid prop drilling by using a top-level state as our source of truth.

Also, using the context API will give our components the ability to communicate and share data at different levels of the application.

Create a src/context/index.ts file and add the following code snippets.

src/context/index.ts


import React from 'react';
import { IUser } from '../api/types';

type State = {
  authUser: IUser | null;
};

type Action = {
  type: string;
  payload: IUser | null;
};

type Dispatch = (action: Action) => void;

const initialState: State = {
  authUser: null,
};

type StateContextProviderProps = { children: React.ReactNode };

const StateContext = React.createContext<
  { state: State; dispatch: Dispatch } | undefined
>(undefined);

const stateReducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'SET_USER': {
      return {
        ...state,
        authUser: action.payload,
      };
    }
    default: {
      throw new Error(`Unhandled action type`);
    }
  }
};

const StateContextProvider = ({ children }: StateContextProviderProps) => {
  const [state, dispatch] = React.useReducer(stateReducer, initialState);
  const value = { state, dispatch };
  return (
    <StateContext.Provider value={value}>{children}</StateContext.Provider>
  );
};

const useStateContext = () => {
  const context = React.useContext(StateContext);

  if (context) {
    return context;
  }

  throw new Error(`useStateContext must be used within a StateContextProvider`);
};

export { StateContextProvider, useStateContext };

Create Components with Material UI

Loading Spinner Component

src/components/FullScreenLoader.tsx


import { Box, CircularProgress, Container } from '@mui/material';

const FullScreenLoader = () => {
  return (
    <Container sx={{ height: '95vh' }}>
      <Box
        display='flex'
        alignItems='center'
        justifyContent='center'
        sx={{ height: '100%' }}
      >
        <CircularProgress />
      </Box>
    </Container>
  );
};

export default FullScreenLoader;

Navigation Header Component

src/components/Header.tsx


import { AppBar, Box, Container, Toolbar, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import { LoadingButton as _LoadingButton } from '@mui/lab';
import { useStateContext } from '../context';
import { useMutation } from '@tanstack/react-query';
import { logoutUserFn } from '../api/authApi';

const LoadingButton = styled(_LoadingButton)`
  padding: 0.4rem;
  color: #222;
  font-weight: 500;

  &:hover {
    transform: translateY(-2px);
  }
`;

const Header = () => {
  const navigate = useNavigate();
  const stateContext = useStateContext();
  const user = stateContext.state.authUser;

  const { mutate: logoutUser, isLoading } = useMutation(
    async () => await logoutUserFn(),
    {
      onSuccess: (data) => {
        window.location.href = '/login';
      },
      onError: (error: any) => {
        if (Array.isArray(error.response.data.error)) {
          error.data.error.forEach((el: any) =>
            toast.error(el.message, {
              position: 'top-right',
            })
          );
        } else {
          toast.error(error.response.data.message, {
            position: 'top-right',
          });
        }
      },
    }
  );

  const onLogoutHandler = async () => {
    logoutUser();
  };

  return (
    <>
      <AppBar position='static' sx={{ backgroundColor: '#fff' }}>
        <Container maxWidth='lg'>
          <Toolbar>
            <Typography
              variant='h6'
              onClick={() => navigate('/')}
              sx={{ cursor: 'pointer', color: '#222' }}
            >
              CodevoWeb
            </Typography>
            <Box display='flex' sx={{ ml: 'auto' }}>
              {!user && (
                <>
                  <LoadingButton
                    sx={{ mr: 2 }}
                    onClick={() => navigate('/register')}
                  >
                    SignUp
                  </LoadingButton>
                  <LoadingButton onClick={() => navigate('/login')}>
                    Login
                  </LoadingButton>
                </>
              )}
              {user && (
                <>
                  <LoadingButton
                    loading={isLoading}
                    onClick={() => navigate('/profile')}
                  >
                    Profile
                  </LoadingButton>
                  <LoadingButton onClick={onLogoutHandler} loading={isLoading}>
                    Logout
                  </LoadingButton>
                </>
              )}
            </Box>
          </Toolbar>
        </Container>
      </AppBar>
    </>
  );
};

export default Header;

Custom TextField component

src/components/FormInput.tsx


import {
  FormHelperText,
  Typography,
  FormControl,
  Input as _Input,
  InputProps,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { FC } from 'react';
import { Controller, useFormContext } from 'react-hook-form';

const Input = styled(_Input)`
  background-color: white;
  padding: 0.4rem 0.7rem;
  margin-bottom: 0.5rem;
`;

type IFormInputProps = {
  name: string;
  label: string;
} & InputProps;

const FormInput: FC<IFormInputProps> = ({ name, label, ...otherProps }) => {
  const {
    control,
    formState: { errors },
  } = useFormContext();

  return (
    <Controller
      control={control}
      defaultValue=''
      name={name}
      render={({ field }) => (
        <FormControl fullWidth sx={{ mb: 2 }}>
          <Typography
            variant='body2'
            sx={{ color: '#2363eb', mb: 1, fontWeight: 500 }}
          >
            {label}
          </Typography>
          <Input
            {...field}
            fullWidth
            disableUnderline
            sx={{ borderRadius: '1rem' }}
            error={!!errors[name]}
            {...otherProps}
          />
          <FormHelperText error={!!errors[name]}>
            {errors[name] ? errors[name].message : ''}
          </FormHelperText>
        </FormControl>
      )}
    />
  );
};

export default FormInput;

Layout Component

src/components/layout.tsx


import { Outlet } from 'react-router-dom';
import Header from './Header';

const Layout = () => {
  return (
    <>
      <Header />
      <Outlet />
    </>
  );
};

export default Layout;

Authentication Guard Component

On the backend, apart from the access and refresh token cookies, we also returned a logged_in cookie that can be accessed by React since it is not an HTTPOnly cookie.

Note: the logged_in cookie has the same lifespan as the access token cookie.

To protect private routes on the client, we need to create a RequireUser guard middleware to check if the user is authenticated.

src/components/requireUser.tsx


import { useCookies } from 'react-cookie';
import { useQuery } from '@tanstack/react-query';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { getMeFn } from '../api/authApi';
import { useStateContext } from '../context';
import FullScreenLoader from './FullScreenLoader';

const RequireUser = ({ allowedRoles }: { allowedRoles: string[] }) => {
  const [cookies] = useCookies(['logged_in']);
  const location = useLocation();
  const stateContext = useStateContext();

  const {
    isLoading,
    isFetching,
    data: user,
  } = useQuery(['authUser'], getMeFn, {
    retry: 1,
    select: (data) => data.data.user,
    onSuccess: (data) => {
      stateContext.dispatch({ type: 'SET_USER', payload: data });
    },
  });

  const loading = isLoading || isFetching;

  if (loading) {
    return <FullScreenLoader />;
  }

  return (cookies.logged_in || user) &&
    allowedRoles.includes(user?.role as string) ? (
    <Outlet />
  ) : cookies.logged_in && user ? (
    <Navigate to='/unauthorized' state={{ from: location }} replace />
  ) : (
    <Navigate to='/login' state={{ from: location }} replace />
  );
};

export default RequireUser;

Authentication Middleware Guard

src/middleware/AuthMiddleware.tsx


import { useCookies } from 'react-cookie';
import { useQuery } from '@tanstack/react-query';
import { getMeFn } from '../api/authApi';
import { useStateContext } from '../context';
import FullScreenLoader from '../components/FullScreenLoader';
import React from 'react';

type AuthMiddlewareProps = {
  children: React.ReactElement;
};

const AuthMiddleware: React.FC<AuthMiddlewareProps> = ({ children }) => {
  const [cookies] = useCookies(['logged_in']);
  const stateContext = useStateContext();

  const query = useQuery(['authUser'], () => getMeFn(), {
    enabled: !!cookies.logged_in,
    select: (data) => data.data.user,
    onSuccess: (data) => {
      stateContext.dispatch({ type: 'SET_USER', payload: data });
    },
  });

  if (query.isLoading && cookies.logged_in) {
    return <FullScreenLoader />;
  }

  return children;
};

export default AuthMiddleware;

React Query Axios Typescript Login User

To perform CREATE, UPDATE, or DELETE operations against the backend API, we use React Query useMutation hook.


const {
    mutate: loginUser,
    isLoading,
    isError,
    error,
    isSuccess,
  } = useMutation((userData: LoginInput) => loginUserFn(userData), {
    onSuccess: () => {
      query.refetch();
    },
  });
  • loginUserFn is a function that performs an asynchronous task and returns a promise.
  • onSuccess is an optional callback function that will be evoked when the mutation is successful and receives the mutation’s result as a parameter.
  • onError is an optional callback function that will be evoked when the mutation encounters an error and gets the error as a parameter.

The object returned by evoking the useMutation hook contains:

  • isLoading : A boolean that indicates whether the mutation is currently executing.
  • isSuccess : A boolean that indicates whether the last mutation attempt was successful.
  • isError : A boolean that indicates whether the last mutation attempt ended in an error.
  • mutate : A function you can evoke to manually trigger the mutation.
  • error : An error object returned after the mutation encounters an error.
  • data : The last successfully resolved data for the mutation.

To make a GET request to the API, we use React Query useQuery hook:


  const {
   isSuccess,
   isLoading,
   refetch,
    isError,
    error,
    data
   } = useQuery(['authUser'], getMeFn, {
    enabled: false,
    select: (data) => {},
    retry: 1,
    onSuccess: (data) => {},
    onError: (error) => {},
  });
  • getMeFn is a function that performs an asynchronous task and returns a promise.
  • authUser (queryKey): A unique key used by React Query for managing the query cache.
  • enabled : A boolean that can be used to disable the query from automatically running.
  • select : A function that can be used to transform the data returned by the query.
  • retry : If set to a number , failed queries will retry until the failed query count reaches that number. If set to true , failed queries will retry infinitely.

Now, let’s create a React Typescript component to log in the registered user with React Query and Axios:

src/pages/login.page.tsx


import { Box, Container, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import FormInput from '../components/FormInput';
import { useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { LoadingButton as _LoadingButton } from '@mui/lab';
import { toast } from 'react-toastify';
import { useMutation, useQuery } from '@tanstack/react-query';
import { getMeFn, loginUserFn } from '../api/authApi';
import { useStateContext } from '../context';

const LoadingButton = styled(_LoadingButton)`
  padding: 0.6rem 0;
  background-color: #f9d13e;
  color: #2363eb;
  font-weight: 500;

  &:hover {
    background-color: #ebc22c;
    transform: translateY(-2px);
  }
`;

const LinkItem = styled(Link)`
  text-decoration: none;
  color: #2363eb;
  &:hover {
    text-decoration: underline;
  }
`;

const loginSchema = object({
  email: string()
    .min(1, 'Email address is required')
    .email('Email Address is invalid'),
  password: string()
    .min(1, 'Password is required')
    .min(8, 'Password must be more than 8 characters')
    .max(32, 'Password must be less than 32 characters'),
});

export type LoginInput = TypeOf<typeof loginSchema>;

const LoginPage = () => {
  const navigate = useNavigate();
  const location = useLocation();

  const from = ((location.state as any)?.from.pathname as string) || '/';

  const methods = useForm<LoginInput>({
    resolver: zodResolver(loginSchema),
  });

  const stateContext = useStateContext();

  // API Get Current Logged-in user
  const query = useQuery(['authUser'], getMeFn, {
    enabled: false,
    select: (data) => data.data.user,
    retry: 1,
    onSuccess: (data) => {
      stateContext.dispatch({ type: 'SET_USER', payload: data });
    },
  });

  //  API Login Mutation
  const { mutate: loginUser, isLoading } = useMutation(
    (userData: LoginInput) => loginUserFn(userData),
    {
      onSuccess: () => {
        query.refetch();
        toast.success('You successfully logged in');
        navigate(from);
      },
      onError: (error: any) => {
        if (Array.isArray((error as any).response.data.error)) {
          (error as any).response.data.error.forEach((el: any) =>
            toast.error(el.message, {
              position: 'top-right',
            })
          );
        } else {
          toast.error((error as any).response.data.message, {
            position: 'top-right',
          });
        }
      },
    }
  );

  const {
    reset,
    handleSubmit,
    formState: { isSubmitSuccessful },
  } = methods;

  useEffect(() => {
    if (isSubmitSuccessful) {
      reset();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isSubmitSuccessful]);

  const onSubmitHandler: SubmitHandler<LoginInput> = (values) => {
    // ? Executing the loginUser Mutation
    loginUser(values);
  };

  return (
    <Container
      maxWidth={false}
      sx={{
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        minHeight: '100vh',
        backgroundColor: '#2363eb',
      }}
    >
      <Box
        sx={{
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          flexDirection: 'column',
        }}
      >
        <Typography
          textAlign='center'
          component='h1'
          sx={{
            color: '#f9d13e',
            fontWeight: 600,
            fontSize: { xs: '2rem', md: '3rem' },
            mb: 2,
            letterSpacing: 1,
          }}
        >
          Welcome Back!
        </Typography>
        <Typography
          variant='body1'
          component='h2'
          sx={{ color: '#e5e7eb', mb: 2 }}
        >
          Login to have access!
        </Typography>

        <FormProvider {...methods}>
          <Box
            component='form'
            onSubmit={handleSubmit(onSubmitHandler)}
            noValidate
            autoComplete='off'
            maxWidth='27rem'
            width='100%'
            sx={{
              backgroundColor: '#e5e7eb',
              p: { xs: '1rem', sm: '2rem' },
              borderRadius: 2,
            }}
          >
            <FormInput name='email' label='Email Address' type='email' />
            <FormInput name='password' label='Password' type='password' />

            <Typography
              sx={{ fontSize: '0.9rem', mb: '1rem', textAlign: 'right' }}
            >
              <LinkItem to='/' style={{ color: '#333' }}>
                Forgot Password?
              </LinkItem>
            </Typography>

            <LoadingButton
              variant='contained'
              sx={{ mt: 1 }}
              fullWidth
              disableElevation
              type='submit'
              loading={isLoading}
            >
              Login
            </LoadingButton>

            <Typography sx={{ fontSize: '0.9rem', mt: '1rem' }}>
              Need an account? <LinkItem to='/register'>Sign Up Here</LinkItem>
            </Typography>
          </Box>
        </FormProvider>
      </Box>
    </Container>
  );
};

export default LoginPage;

React Query Axios Register User

Let’s use Typescript, and React Query with Axios POST request to register a new user.

src/pages/register.page.tsx


import { Box, Container, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import FormInput from '../components/FormInput';
import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { LoadingButton as _LoadingButton } from '@mui/lab';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { signUpUserFn } from '../api/authApi';

const LoadingButton = styled(_LoadingButton)`
  padding: 0.6rem 0;
  background-color: #f9d13e;
  color: #2363eb;
  font-weight: 500;

  &:hover {
    background-color: #ebc22c;
    transform: translateY(-2px);
  }
`;

const LinkItem = styled(Link)`
  text-decoration: none;
  color: #2363eb;
  &:hover {
    text-decoration: underline;
  }
`;

const registerSchema = object({
  name: string().min(1, 'Full name is required').max(100),
  email: string()
    .min(1, 'Email address is required')
    .email('Email Address is invalid'),
  password: string()
    .min(1, 'Password is required')
    .min(8, 'Password must be more than 8 characters')
    .max(32, 'Password must be less than 32 characters'),
  passwordConfirm: string().min(1, 'Please confirm your password'),
}).refine((data) => data.password === data.passwordConfirm, {
  path: ['passwordConfirm'],
  message: 'Passwords do not match',
});

export type RegisterInput = TypeOf<typeof registerSchema>;

const RegisterPage = () => {
  const navigate = useNavigate();

  const methods = useForm<RegisterInput>({
    resolver: zodResolver(registerSchema),
  });

  // ? Calling the Register Mutation
  const { mutate, isLoading } = useMutation(
    (userData: RegisterInput) => signUpUserFn(userData),
    {
      onSuccess(data) {
        toast.success(data?.message);
        navigate('/verifyemail');
      },
      onError(error: any) {
        if (Array.isArray((error as any).response.data.error)) {
          (error as any).response.data.error.forEach((el: any) =>
            toast.error(el.message, {
              position: 'top-right',
            })
          );
        } else {
          toast.error((error as any).response.data.message, {
            position: 'top-right',
          });
        }
      },
    }
  );

  const {
    reset,
    handleSubmit,
    formState: { isSubmitSuccessful },
  } = methods;

  useEffect(() => {
    if (isSubmitSuccessful) {
      reset();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isSubmitSuccessful]);

  const onSubmitHandler: SubmitHandler<RegisterInput> = (values) => {
    // ? Execute the Mutation
    mutate(values);
  };

  return (
    <Container
      maxWidth={false}
      sx={{
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        minHeight: '100vh',
        backgroundColor: '#2363eb',
      }}
    >
      <Box
        sx={{
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          flexDirection: 'column',
        }}
      >
        <Typography
          textAlign='center'
          component='h1'
          sx={{
            color: '#f9d13e',
            fontSize: { xs: '2rem', md: '3rem' },
            fontWeight: 600,
            mb: 2,
            letterSpacing: 1,
          }}
        >
          Welcome to CodevoWeb!
        </Typography>
        <Typography component='h2' sx={{ color: '#e5e7eb', mb: 2 }}>
          Sign Up To Get Started!
        </Typography>

        <FormProvider {...methods}>
          <Box
            component='form'
            onSubmit={handleSubmit(onSubmitHandler)}
            noValidate
            autoComplete='off'
            maxWidth='27rem'
            width='100%'
            sx={{
              backgroundColor: '#e5e7eb',
              p: { xs: '1rem', sm: '2rem' },
              borderRadius: 2,
            }}
          >
            <FormInput name='name' label='Full Name' />
            <FormInput name='email' label='Email Address' type='email' />
            <FormInput name='password' label='Password' type='password' />
            <FormInput
              name='passwordConfirm'
              label='Confirm Password'
              type='password'
            />
            <Typography sx={{ fontSize: '0.9rem', mb: '1rem' }}>
              Already have an account?{' '}
              <LinkItem to='/login'>Login Here</LinkItem>
            </Typography>

            <LoadingButton
              variant='contained'
              sx={{ mt: 1 }}
              fullWidth
              disableElevation
              type='submit'
              loading={isLoading}
            >
              Sign Up
            </LoadingButton>
          </Box>
        </FormProvider>
      </Box>
    </Container>
  );
};

export default RegisterPage;

React Query Axios Verify User’s Email

Next, let’s create a React Typescript component with React Query and Axios to verify the registered user’s email address.

src/pages/verifyemail.page.tsx


import { Box, Container, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import FormInput from '../components/FormInput';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { LoadingButton as _LoadingButton } from '@mui/lab';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { verifyEmailFn } from '../api/authApi';

const LoadingButton = styled(_LoadingButton)`
  padding: 0.6rem 0;
  background-color: #f9d13e;
  color: #2363eb;
  font-weight: 500;

  &:hover {
    background-color: #ebc22c;
    transform: translateY(-2px);
  }
`;

const verificationCodeSchema = object({
  verificationCode: string().min(1, 'Verification code is required'),
});

export type VerificationCodeInput = TypeOf<typeof verificationCodeSchema>;

const EmailVerificationPage = () => {
  const { verificationCode } = useParams();

  const methods = useForm<VerificationCodeInput>({
    resolver: zodResolver(verificationCodeSchema),
  });

  // ? API Login Mutation
  const { mutate: verifyEmail, isLoading } = useMutation(
    (verificationCode: string) => verifyEmailFn(verificationCode),
    {
      onSuccess: (data) => {
        toast.success(data?.message);
        navigate('/login');
      },
      onError(error: any) {
        if (Array.isArray((error as any).data.error)) {
          (error as any).data.error.forEach((el: any) =>
            toast.error(el.message, {
              position: 'top-right',
            })
          );
        } else {
          toast.error((error as any).data.message, {
            position: 'top-right',
          });
        }
      },
    }
  );

  const navigate = useNavigate();

  const {
    reset,
    handleSubmit,
    formState: { isSubmitSuccessful },
  } = methods;

  useEffect(() => {
    if (isSubmitSuccessful) {
      reset();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isSubmitSuccessful]);

  useEffect(() => {
    if (verificationCode) {
      reset({ verificationCode });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const onSubmitHandler: SubmitHandler<VerificationCodeInput> = ({
    verificationCode,
  }) => {
    // ? Executing the verifyEmail Mutation
    verifyEmail(verificationCode);
  };

  return (
    <Container
      maxWidth={false}
      sx={{
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        minHeight: '100vh',
        backgroundColor: '#2363eb',
      }}
    >
      <Box
        sx={{
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          flexDirection: 'column',
        }}
      >
        <Typography
          textAlign='center'
          component='h1'
          sx={{
            color: '#f9d13e',
            fontWeight: 600,
            fontSize: { xs: '2rem', md: '3rem' },
            mb: 2,
            letterSpacing: 1,
          }}
        >
          Verify Email Address
        </Typography>

        <FormProvider {...methods}>
          <Box
            component='form'
            onSubmit={handleSubmit(onSubmitHandler)}
            noValidate
            autoComplete='off'
            maxWidth='27rem'
            width='100%'
            sx={{
              backgroundColor: '#e5e7eb',
              p: { xs: '1rem', sm: '2rem' },
              borderRadius: 2,
            }}
          >
            <FormInput name='verificationCode' label='Verification Code' />

            <LoadingButton
              variant='contained'
              sx={{ mt: 1 }}
              fullWidth
              disableElevation
              type='submit'
              loading={isLoading}
            >
              Verify Email
            </LoadingButton>
          </Box>
        </FormProvider>
      </Box>
    </Container>
  );
};

export default EmailVerificationPage;

Create Some Useful Pages with React and Material UI

Home Page

src/pages/home.page.tsx


import { Box, Container, Typography } from '@mui/material';

const HomePage = () => {
  return (
    <Container
      maxWidth={false}
      sx={{ backgroundColor: '#2363eb', minHeight: '100vh', pt: '5rem' }}
    >
      <Box
        maxWidth='md'
        sx={{
          backgroundColor: '#ece9e9',
          height: '15rem',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          mx: 'auto',
        }}
      >
        <Typography
          variant='h2'
          component='h1'
          sx={{ color: '#1f1e1e', fontWeight: 500 }}
        >
          Home Page
        </Typography>
      </Box>
    </Container>
  );
};

export default HomePage;

Profile Page

src/pages/profile.page.tsx


import { Box, Container, Typography } from '@mui/material';
import { useStateContext } from '../context';

const ProfilePage = () => {
  const stateContext = useStateContext();

  const user = stateContext.state.authUser;

  return (
    <Container
      maxWidth={false}
      sx={{
        backgroundColor: '#2363eb',
        minHeight: '100vh',
      }}
    >
      <Box
        maxWidth='lg'
        sx={{
          backgroundColor: '#ece9e9',
          maxHeight: '20rem',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          p: '2rem',
          mx: 'auto',
        }}
      >
        <Typography
          variant='h2'
          component='h1'
          sx={{ color: '#1f1e1e', fontWeight: 500 }}
        >
          Profile Page
        </Typography>
        <Box sx={{ mt: 2 }}>
          <Typography gutterBottom>
            <strong>Id:</strong> {user?.id}
          </Typography>
          <Typography gutterBottom>
            <strong>Full Name:</strong> {user?.name}
          </Typography>
          <Typography gutterBottom>
            <strong>Email Address:</strong> {user?.email}
          </Typography>
          <Typography gutterBottom>
            <strong>Role:</strong> {user?.role}
          </Typography>
        </Box>
      </Box>
    </Container>
  );
};

export default ProfilePage;

Unauthorized Page

src/pages/unauthorize.page.tsx


import { Box, Container, Typography } from '@mui/material';

const UnauthorizePage = () => {
  return (
    <Container maxWidth='lg'>
      <Box
        sx={{
          backgroundColor: '#ece9e9',
          mt: '2rem',
          height: '15rem',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        <Typography
          variant='h2'
          component='h1'
          sx={{ color: '#1f1e1e', fontWeight: 500 }}
        >
          Unauthorized Page
        </Typography>
      </Box>
    </Container>
  );
};

export default UnauthorizePage;

Set up Routing with React-Router-Dom

React Router Dom v6 now supports object-based routing and since we are using Typescript we have to import the RouteObjecttype” from “react-router-dom“.

Next, we have to create an array of route objects and specify the routes to the different pages we defined above.

src/router/index.tsx


import { Suspense, lazy } from 'react';
import type { RouteObject } from 'react-router-dom';
import FullScreenLoader from '../components/FullScreenLoader';
import Layout from '../components/layout';
import RequireUser from '../components/requireUser';
import HomePage from '../pages/home.page';
import LoginPage from '../pages/login.page';
import ProfilePage from '../pages/profile.page';

const Loadable =
  (Component: React.ComponentType<any>) => (props: JSX.IntrinsicAttributes) =>
    (
      <Suspense fallback={<FullScreenLoader />}>
        <Component {...props} />
      </Suspense>
    );

const RegisterPage = Loadable(lazy(() => import('../pages/register.page')));
const UnauthorizePage = Loadable(
  lazy(() => import('../pages/unauthorize.page'))
);
const EmailVerificationPage = Loadable(
  lazy(() => import('../pages/verifyemail.page'))
);

const authRoutes: RouteObject = {
  path: '*',
  children: [
    {
      path: 'login',
      element: <LoginPage />,
    },
    {
      path: 'register',
      element: <RegisterPage />,
    },
    {
      path: 'verifyemail',
      element: <EmailVerificationPage />,
      children: [
        {
          path: ':verificationCode',
          element: <EmailVerificationPage />,
        },
      ],
    },
  ],
};

const normalRoutes: RouteObject = {
  path: '*',
  element: <Layout />,
  children: [
    {
      index: true,
      element: <HomePage />,
    },
    {
      path: 'profile',
      element: <RequireUser allowedRoles={['user', 'admin']} />,
      children: [
        {
          path: '',
          element: <ProfilePage />,
        },
      ],
    },
    {
      path: 'unauthorized',
      element: <UnauthorizePage />,
    },
  ],
};

const routes: RouteObject[] = [authRoutes, normalRoutes];

export default routes;

Now in ./src/App.tsx file, import the useRoutes hook provided by React Router Dom to render the routes defined above.

src/App.tsx


import { CssBaseline } from '@mui/material';
import { useRoutes } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import routes from './router';

function App() {
  const content = useRoutes(routes);
  return (
    <>
      <CssBaseline />
      <ToastContainer />
      {content}
    </>
  );
}

export default App;

Add the QueryClient and Context API to the Root App

To make the data stored with the Context API available to all our React components, we need to wrap the root app in the StateContextProvider component.

Also, the root app needs to be wrapped in the BrowserRouter component provided by React-Router-Dom to enable react-router-dom to control the navigation between the different pages.

src/index.tsx


import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { StateContextProvider } from './context';
import { BrowserRouter as Router } from 'react-router-dom';
import AuthMiddleware from './middleware/AuthMiddleware';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 1000,
    },
  },
});

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <Router>
        <StateContextProvider>
          <AuthMiddleware>
            <App />
          </AuthMiddleware>
        </StateContextProvider>
        <ReactQueryDevtools initialIsOpen={false} />
      </Router>
    </QueryClientProvider>
  </React.StrictMode>
);

Conclusion

With this React Query, Material UI, React Hook Form, and Axios interceptors example in Typescript, you’ve learned how to implement JSON Web Token Authentication and Authorization in React.js.

React Query and Axios Interceptors Source Code

Check out the complete source code for: