tRPC is a toolkit that allows developers to statically type their API endpoints and share those types between the client and server, without installing extra libraries for code generation.

When we build full-stack applications with TypeScript, it becomes difficult to share types between the frontend and the backend.

Currently, GraphQL is the dominant library used to build and consume fully typesafe APIs in TypeScript. It offers a radically new approach to building type-safety APIs with more flexibility and control.

However, since GraphQL is a query language, it doesn’t leverage the full power of TypeScript and depends on code generation and already-defined schemas to implement typesafe APIs.

This is where tRPC was introduced to solve the complexities and flaws of GraphQL. tRPC allows us to build full-stack TypeScript applications and share the types directly between the client and server.

In this article, you’ll learn how to add JWT Authentication to your React.js, TypeScript, tRPC Client, tailwindCss and React Query projects.

tRPC Client and Server with React.js, Express, and Node.js Series:

  1. Build tRPC API with React.js, Node.js & MongoDB: Project Setup
  2. Build tRPC API with React.js & Node.js: Access and Refresh Tokens
  3. Full-Stack App tRPC, React.js, & Node.js: JWT Authentication

More practice:

Full-Stack App tRPC, React.js, & Node.js JWT Authentication

Prerequisites

Before we begin, you should:

  • Have basic knowledge of JavaScript, TypeScript, and React.js
  • Have basic knowledge of CSS and tailwindCss

React Query, tRPC Client, & React.js Overview

The user clicks on the “SignUp” button on the Homepage to create an account.

trpc client and server react home page

The user is taken to the signup page where he is required to provide his credentials.

trpc client and server react register new user with react query

Next, the user is redirected to the login page where he needs to provide his credentials to log in.

trpc client and server react login user with react query

After the tRPC Express server has successfully authenticated the user, React redirects the user to the profile page.

trpc client and server react profile page

You can inspect the “Application” tab in the dev tools to see the cookies returned by the tRPC Express server.

jwt refresh access token cookies in the browser

Setup React Query, tRPC Client, and React.js

We are going to use React.js, tRPC Client, and React Query along with the React binding for tRPC.

Follow the Project Setup article to set up the tRPC client and server with React, Node.js, Express, and tailwindCss before continuing with this article.

Now change the directory into the client folder cd packages/client and run this command to install the AppRouter type we exported from the tRPC server.


yarn add server@1.0.0
  • server – is the name used in the tRPC server package.json file.
  • @1.0.0 – is the version used in the tRPC server package.json file.

Managing State with Zustand

Redux is probably the most popular React state management library but it requires some amount of code to get it up and running with React. To update a single state, you will need to create actions, dispatch functions, and reducers for Redux to work properly.

Similar to React Context API, Redux needs to be wrapped with a provider component to make the Redux store available to any nested components that need to access the Redux store.

With Zustand, you can create a globally available state with few lines of code and inject it into any component, without the need to wrap a provider around your root app.

Another exciting feature of Zustand is that you can retrieve data from your state in functions that are not React components.

Before we start creating the React state with Zustand, create a packages/client/src/libs/types.ts file and add the following TypeScript types.

packages/client/src/libs/types.ts


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


Open your terminal and change the directory to the client folder cd packages/client and run this command to install the Zustand state management library:


yarn add zustand

Next, create a packages/client/src/store/index.ts file and add the code below to set up the Zustand store for our application.

packages/client/src/store/index.ts


import create from 'zustand';
import { IUser } from '../libs/types';

type Store = {
  authUser: IUser | null;
  uploadingImage: boolean;
  pageLoading: boolean;
  setAuthUser: (user: IUser) => void;
  setUploadingImage: (isUploading: boolean) => void;
  setPageLoading: (isLoading: boolean) => void;
};

const useStore = create<Store>((set) => ({
  authUser: null,
  uploadingImage: false,
  pageLoading: false,
  setAuthUser: (user) => set((state) => ({ ...state, authUser: user })),
  setUploadingImage: (isUploading) =>
    set((state) => ({ ...state, uploadingImage: isUploading })),
  setPageLoading: (isLoading) =>
    set((state) => ({ ...state, pageLoading: isLoading })),
}));

export default useStore;

  • authUser – This state will store the authenticated user’s credentials. Defaults to null.
  • uploadingImage – This state will indicate whether an image is being uploaded to Cloudinary.
  • pageLoading – This state will indicate whether a request is being processed by the tRPC server. When a request is in flight, it will be set to true and if the app gets a response from the tRPC API, it will be set to false.
  • setAuthUser – This function will add the authenticated user’s credentials to the “authUser” state.
  • setUploadingImage – This function will change the status of the “uploadingImage” state.
  • setPageLoading – This function will change the status of the “pageLoading” state.

Create React Components with tailwindCss

In React projects, it’s a good practice to make components reusable to follow the DRY (Don’t Repeat Yourself) principle. So to achieve this, we’ll create a couple of reusable React components and style them with tailwind CSS. Before that, change the directory to the client folder cd packages/client and run this command to install these dependencies.


yarn add react-router-dom tailwind-merge react-cookie

  • react-router-dom – A library that allows us to implement dynamic routing in React web apps.
  • tailwind-merge – A library for merging Tailwind CSS classes in JS without style conflicts.
  • react-cookie – A library that uses React hooks to access and modify cookies.

Create a Header Component with tailwindCss

The first component will be a Header. This component will display a handful of navigation menus and it will also have logic for logging out a user. To do that, we’ll create a mutation hook to evoke the logoutUser procedure on the tRPC server.

When the logout menu is clicked, the handleLogout() function will be called which will also evoke the logoutUser() method to trigger the mutation. The mutation hook will then evoke the “logoutUser” procedure to log the user out of the tRPC API.

packages/client/src/components/Header.tsx


import { useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import useStore from "../store";
import { trpc } from "../trpc";

const Header = () => {
  const store = useStore();
  const user = store.authUser;

  const queryClient = useQueryClient();
  const { mutate: logoutUser } = trpc.logoutUser.useMutation({
    onSuccess(data) {
      queryClient.clear();
      document.location.href = "/login";
    },
    onError(error) {
      queryClient.clear();
      document.location.href = "/login";
    },
  });

  const handleLogout = () => {
    logoutUser();
  };

  return (
    <header className="bg-white h-20">
      <nav className="h-full flex justify-between container items-center">
        <div>
          <Link to="/" className="text-ct-dark-600 text-2xl font-semibold">
            CodevoWeb
          </Link>
        </div>
        <ul className="flex items-center gap-4">
          <li>
            <Link to="/" className="text-ct-dark-600">
              Home
            </Link>
          </li>
          {!user && (
            <>
              <li>
                <Link to="/register" className="text-ct-dark-600">
                  SignUp
                </Link>
              </li>
              <li>
                <Link to="/login" className="text-ct-dark-600">
                  Login
                </Link>
              </li>
            </>
          )}
          {user && (
            <>
              <li>
                <Link to="/profile" className="text-ct-dark-600">
                  Profile
                </Link>
              </li>
              <li className="cursor-pointer">Create Post</li>
              <li className="cursor-pointer" onClick={handleLogout}>
                Logout
              </li>
            </>
          )}
        </ul>
      </nav>
    </header>
  );
};

export default Header;

The tRPC API will then send expired cookies to delete the existing ones in the browser. If the mutation resolves in success or error, React Query will evoke thequeryClient.clear() method to clear the server state before React will redirect the user to the login page.

Create a Loading Spinner with tailwindCss

The next reusable component will be a Spinner. This component will accept a couple of props to make it customizable. By default, the Spinner will be hidden but it will be made visible when a background process is running or the API is processing the request.

To create the Spinner component, go into the packages/client/src/components folder and create a Spinner.tsx file. Then, open the Spinner.tsx file and add this TSX code.

packages/client/src/components/Spinner.tsx


import React from 'react';
import { twMerge } from 'tailwind-merge';
type SpinnerProps = {
  width?: number;
  height?: number;
  color?: string;
  bgColor?: string;
};
const Spinner: React.FC<SpinnerProps> = ({
  width = 5,
  height = 5,
  color,
  bgColor,
}) => {
  return (
    <svg
      role='status'
      className={twMerge(
        'w-5 h-5 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600',
        `w-${width} h-${height} ${color} ${bgColor}`
      )}
      viewBox='0 0 100 101'
      fill='none'
      xmlns='http://www.w3.org/2000/svg'
    >
      <path
        d='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z'
        fill='currentColor'
      />
      <path
        d='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z'
        fill='currentFill'
      />
    </svg>
  );
};

export default Spinner;

Create a Screen Loader with React and tailwindCss

Here, you’ll create a component that will cover the full screen and display the Spinner component when a request is fired to the tRPC API. Navigate to the packages/client/src/components directory and create a FullScreenLoader.tsx file. Next, add the code below to the FullScreenLoader.tsx file.

packages/client/src/components/FullScreenLoader.tsx


import Spinner from './Spinner';

const FullScreenLoader = () => {
  return (
    <div className='w-screen h-screen fixed'>
      <div className='absolute top-64 left-1/2 -translate-x-1/2'>
        <Spinner width={8} height={8} />
      </div>
    </div>
  );
};

export default FullScreenLoader;

Create a Loading Button with React and tailwindCss

Now let’s create a custom button that will have the Spinner component and a text element. The Spinner component will be hidden by default but when the form is submitted and the form data is posted to the tRPC API, it will be made visible to give the user visual feedback that the request is being processed by the API.

packages/client/src/components/LoadingButton.tsx


import React from 'react';
import Spinner from './Spinner';

type LoadingButtonProps = {
  loading: boolean;
  btnColor?: string;
  textColor?: string;
  children: React.ReactNode;
};

export const LoadingButton: React.FC<LoadingButtonProps> = ({
  textColor = 'text-white',
  btnColor = 'bg-ct-yellow-600',
  children,
  loading = false,
}) => {
  return (
    <button
      type='submit'
      className={`w-full py-3 font-semibold ${btnColor} rounded-lg outline-none border-none flex justify-center ${
        loading ? 'bg-[#ccc]' : ''
      }`}
    >
      {loading ? (
        <div className='flex items-center gap-3'>
          <Spinner />
          <span className='text-slate-500'>Loading...</span>
        </div>
      ) : (
        <span className={`${textColor}`}>{children}</span>
      )}
    </button>
  );
};

React Layout Component with React-Router-Dom

To avoid repeating the Header component in all the pages that need it, let’s create a Layout component with the help of React-Router-DOM’s Outlet component to render the pages as child routes.

So, create a Layout.tsx file in the packages/client/src/components folder and add the code snippets below.

packages/client/src/components/Layout.tsx


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

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

export default Layout;

Create an InputField with React-Hook-Form

Since there would be similar input elements in the login and registration forms, let’s create a reusable input component to prevent us from repeating the same block of code in all the forms that need it. To begin, open your terminal and install React-Hook-Form, Zod, and React-Hook-Form’s validation resolver.


yarn add react-hook-form zod @hookform/resolvers

  • react-hook-form – A library for validating forms in React.js
  • zod – A TypeScript-first schema declaration and validation library

After the installations, create a FormInput.tsx file in the client/src/components directory and add the following code.

packages/client/src/components/FormInput.tsx


import React from 'react';
import { useFormContext } from 'react-hook-form';

type FormInputProps = {
  label: string;
  name: string;
  type?: string;
};

const FormInput: React.FC<FormInputProps> = ({
  label,
  name,
  type = 'text',
}) => {
  const {
    register,
    formState: { errors },
  } = useFormContext();
  return (
    <div className=''>
      <label htmlFor={name} className='block text-ct-blue-600 mb-3'>
        {label}
      </label>
      <input
        type={type}
        placeholder=' '
        className='block w-full rounded-2xl appearance-none focus:outline-none py-2 px-4'
        {...register(name)}
      />
      {errors[name] && (
        <span className='text-red-500 text-xs pt-1 block'>
          {errors[name]?.message as string}
        </span>
      )}
    </div>
  );
};

export default FormInput;

Instead of passing the form context as a prop to the Input component, we used the useFormContext hook to access the form context. Later, we’ll wrap the FormProvider component around the form for the useFormContext hook to work properly.

Create an Image Uploader with React and Cloudinary

To begin, register for an account on Cloudinary and follow the following steps to configure the upload preset on your account.

Step 1: Click on the Settings icon on the top navigation bar.

cloudinary dashboard

Step 2: Click the “Upload” tab on the settings page

cloudinary dashboard upload tab

Step 3: Scroll down to the “Upload presets” section and click on the “Add upload preset” link

cloudinary dashboard upload settings

Step 4: Enter the “Upload preset name” and change the “Signing Mode” to “Unsigned“. Input the preset name as the folder name.

Once you are done, click on the “Upload Manipulations” tab on the left sidebar.

cloudinary dashboard upload settings preset

Step 5: Click on the “Edit” link under the “Incoming Transformation” section.

cloudinary dashboard add upload preset

Step 6: Change the width and height to your preferred size and change the quality type from “Manual” to “Automatic – best quality“.

Once you are done, click the “ok” button.

cloudinary dashboard traform image

Step 7: Click on the “Save” button to save the changes.

cloudinary dashboard click on the save button

For security reasons don’t expose your “upload preset name” to the public to avoid unsolicited upload of images by users.

I will definitely change my account details at the end of this tutorial.

Now create packages/client/src/components/FileUpload.tsx file and add the following code to process the upload request.

packages/client/src/components/FileUpload.tsx


import React, { useCallback } from 'react';
import { Controller, useController, useFormContext } from 'react-hook-form';
import useStore from '../store';
import Spinner from './Spinner';

const CLOUDINARY_UPLOAD_PRESET = 'trpc-api';
const CLOUDINARY_URL = 'https://api.cloudinary.com/v1_1/Codevo/image/upload';

type FileUpLoaderProps = {
  name: string;
};
const FileUpLoader: React.FC<FileUpLoaderProps> = ({ name }) => {
  const {
    control,
    formState: { errors },
  } = useFormContext();
  const { field } = useController({ name, control });
  const store = useStore();

  const onFileDrop = useCallback(
    async (e: React.SyntheticEvent<EventTarget>) => {
      const target = e.target as HTMLInputElement;
      if (!target.files) return;
      const newFile = Object.values(target.files).map((file: File) => file);
      const formData = new FormData();
      formData.append('file', newFile[0]);
      formData.append('upload_preset', CLOUDINARY_UPLOAD_PRESET);

      store.setUploadingImage(true);
      const data = await fetch(CLOUDINARY_URL, {
        method: 'POST',
        body: formData,
      })
        .then((res) => {
          store.setUploadingImage(false);

          return res.json();
        })
        .catch((err) => {
          store.setUploadingImage(false);
          console.log(err);
        });

      if (data.secure_url) {
        field.onChange(data.secure_url);
      }
    },

    [field, store]
  );

  return (
    <Controller
      name={name}
      defaultValue=''
      control={control}
      render={({ field: { name, onBlur, ref } }) => (
        <>
          <div className='mb-2 flex justify-between items-center'>
            <div>
              <span className='block mb-2'>Choose profile photo</span>
              <input
                className='block text-sm mb-2 text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-violet-50 file:text-violet-700 hover:file:bg-violet-100'
                type='file'
                name={name}
                onBlur={onBlur}
                ref={ref}
                onChange={onFileDrop}
                multiple={false}
                accept='image/jpg, image/png, image/jpeg'
              />
            </div>
            <div>
              {store.uploadingImage && <Spinner color='text-yellow-400' />}
            </div>
          </div>
          <p
            className={`text-red-500 text-xs italic mb-2 ${
              errors[name] ? 'visible' : 'invisible'
            }`}
          >
            {errors[name] && (errors[name]?.message as string)}
          </p>
        </>
      )}
    />
  );
};

export default FileUpLoader;

From the above code, you will need two pieces of information, the Cloudinary RESTful image upload URL and your preset name.

For example, to upload an image to your account, you would have to make a POST request with the formData to:

POST https://api.cloudinary.com/v1_1/:cloud_name/image/upload

Where :cloud_name is your Cloudinary Cloud name. Also, you need to append your upload preset name to the formData.

React Query and tRPC Client: Register User

At this point, we are ready to implement the authentication logic of the tRPC client. The first component will add new users to the tRPC API. This component will contain a form that has name, email, photo, password, and password confirm input elements. The validation of the input fields will be handled by React-Hook-Form and the validation rules will be defined with Zod.

To begin, change the directory to the client folder cd packages/client and install the react-toastify package. This library will allow us to show alert notifications.


yarn add react-toastify

With that out of the way, create a register.page.tsx file in the packages/client/src/pages directory and add the code snippets below.

packages/client/src/pages/register.page.tsx


import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import FormInput from "../components/FormInput";
import { LoadingButton } from "../components/LoadingButton";
import { toast } from "react-toastify";
import { trpc } from "../trpc";
import FileUpLoader from "../components/FileUpload";

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"),
  photo: string().min(1, "Photo is required").url("Photo URL 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 { isLoading, mutate: SignUpUser } = trpc.registerUser.useMutation({
    onSuccess: (data) => {
      toast.success("Registration successful");
      navigate("/login");
    },
    onError: (error) => {
      toast.error(error.message, {
        type: "error",
        position: "top-right",
      });
    },
  });

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

  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
    SignUpUser(values);
  };

  return (
    <section className="py-8 bg-ct-blue-600 min-h-screen grid place-items-center">
      <div className="w-full">
        <h1 className="text-4xl xl:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
          Welcome to CodevoWeb!
        </h1>
        <h2 className="text-lg text-center mb-4 text-ct-dark-200">
          Sign Up To Get Started!
        </h2>
        <FormProvider {...methods}>
          <form
            onSubmit={handleSubmit(onSubmitHandler)}
            className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
          >
            <FormInput label="Full Name" name="name" />
            <FormInput label="Email" name="email" type="email" />
            <FormInput label="Password" name="password" type="password" />
            <FormInput
              label="Confirm Password"
              name="passwordConfirm"
              type="password"
            />
            <FileUpLoader name="photo" />
            <span className="block">
              Already have an account?{" "}
              <Link to="/login" className="text-ct-blue-600">
                Login Here
              </Link>
            </span>
            <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
              Sign Up
            </LoadingButton>
          </form>
        </FormProvider>
      </div>
    </section>
  );
};

export default RegisterPage;

We created a validation schema with Zod and assigned it the registerSchema variable. Then, we inferred a TypeScript type from the schema and assigned it to RegisterInput .

Next, we created a registerUser mutation hook to submit the registration form data to the tRPC API. When the form is submitted and is valid, React-Hook-Form will evoke the onSubmitHandler() function we passed to the handleSubmit() method.

The SignUpUser method will then be evoked with the form data which will trigger the registerUser hook to submit the form data to the tRPC server. The mutation hook will evoke the registerUser procedure on the tRPC API to add the new user to the database. If the tRPC API adds the new user to the database, React will redirect the user to the login page. Otherwise, an error message will be displayed.

React Query and tRPC Client: Login User

Now that we are able to register a user, let’s create a component to sign the user into the tRPC app. Similar to the user registration component, we’ll define a schema with Zod and provide it to React-Hook-Form’s useForm() hook for the form validation.

After that, we’ll create a mutation hook with React Query to submit the form data to the tRPC backend. So, go into the packages/client/src/pages folder and create a login.page.tsx file. Next, open the newly-created login.page.tsx file and add the code below.

packages/client/src/pages/login.page.tsx


import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import FormInput from "../components/FormInput";
import { LoadingButton } from "../components/LoadingButton";
import { toast } from "react-toastify";
import { trpc } from "../trpc";

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) || "/profile";

  const { isLoading, mutate: loginUser } = trpc.loginUser.useMutation({
    onSuccess(data) {
      toast("Logged in successfully", {
        type: "success",
        position: "top-right",
      });
      navigate(from);
    },
    onError(error) {
      toast(error.message, {
        type: "error",
        position: "top-right",
      });
    },
  });

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

  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 (
    <section className="bg-ct-blue-600 min-h-screen grid place-items-center">
      <div className="w-full">
        <h1 className="text-4xl xl:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
          Welcome Back
        </h1>
        <h2 className="text-lg text-center mb-4 text-ct-dark-200">
          Login to have access
        </h2>
        <FormProvider {...methods}>
          <form
            onSubmit={handleSubmit(onSubmitHandler)}
            className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
          >
            <FormInput label="Email" name="email" type="email" />
            <FormInput label="Password" name="password" type="password" />

            <div className="text-right">
              <Link to="#" className="">
                Forgot Password?
              </Link>
            </div>
            <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
              Login
            </LoadingButton>
            <span className="block">
              Need an account?{" "}
              <Link to="/register" className="text-ct-blue-600">
                Sign Up Here
              </Link>
            </span>
          </form>
        </FormProvider>
      </div>
    </section>
  );
};

export default LoginPage;

We created a validation schema to validate the email and password input fields and assigned the schema to the loginSchema variable. Then, we inferred a TypeScript type from the schema and assigned it to the LoginInput type.

Next, we created a React Query mutation hook to submit the form data to the tRPC backend API. when the form is submitted and there are no validation errors, React will evoke the onSubmitHandler function we passed to the handleSubmit() method.

This will in turn evoke the loginUser() mutation function and pass the form data as an argument to it. After the mutation has been triggered, the loginUser procedure will be evoked to sign the user into the API. If the user’s credentials are valid and the authentication succeeds, React will display a success alert message and redirect the user to the protected route or the profile page.

React Query and tRPC Client: Authentication Guard

On the tRPC server, we returned a logged_in cookie that is not HTTPOnly to the client or browser. This will enable us to access it from the React application.

Now let’s create an authentication guard to protect the private routes on the React application.

packages/client/src/components/requireUser.tsx


import { useCookies } from "react-cookie";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { IUser } from "../libs/types";
import useStore from "../store";
import { trpc } from "../trpc";
import FullScreenLoader from "./FullScreenLoader";

const RequireUser = ({ allowedRoles }: { allowedRoles: string[] }) => {
  const [cookies] = useCookies(["logged_in"]);
  const location = useLocation();
  const store = useStore();

  const {
    isLoading,
    isFetching,
    data: user,
  } = trpc.getMe.useQuery(undefined, {
    retry: 1,
    select: (data) => data.data.user,
    onSuccess: (data) => {
      store.setAuthUser(data as IUser);
    },
    onError: (error) => {
      console.log(error);
      if (error.message.includes("Could not refresh access token")) {
        document.location.href = "/login";
      }
    },
  });

  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;

The requireUser auth guard will make a tRPC query request to retrieve the authenticated user’s information. This request will only be successful if the user is logged in. When the request results in an error, it means the user doesn’t have a valid access token and React will redirect the user to the login page.

Refresh Access Token Middleware Guard

Let’s create a component to refresh the user’s access token when it expires. To do this, we’ll wrap the middleware guard around the root component. When a user signs into the app, the tRPC API will send access, refresh, and logged_in cookies to the user’s browser. Both the access and refresh tokens are HTTPOnly cookies but the logged_in cookie is not. That means, we can access the value of the logged_in cookie in the React app.

Also, the logged_in token has the same expiration time as the access token. So, when the access token expires, the logged_in token will also expire and the browser will delete both of them automatically.

packages/client/src/middleware/AuthMiddleware.tsx


import { useCookies } from "react-cookie";
import FullScreenLoader from "../components/FullScreenLoader";
import React from "react";
import { trpc } from "../trpc";
import { IUser } from "../libs/types";
import { useQueryClient } from "@tanstack/react-query";
import useStore from "../store";

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

const AuthMiddleware: React.FC<AuthMiddlewareProps> = ({ children }) => {
  const [cookies] = useCookies(["logged_in"]);
  const store = useStore();

  const queryClient = useQueryClient();
  const { refetch } = trpc.refreshToken.useQuery(undefined, {
    retry: 1,
    enabled: false,
    onSuccess: (data) => {
      queryClient.invalidateQueries([["getMe"]]);
    },
  });

  const query = trpc.getMe.useQuery(undefined, {
    enabled: !!cookies.logged_in,
    retry: 1,
    select: (data) => data.data.user,
    onSuccess: (data) => {
      store.setAuthUser(data as IUser);
    },
    onError: (error) => {
      let retryRequest = true;
      if (error.message.includes("must be logged in") && retryRequest) {
        retryRequest = false;
        try {
          refetch({ throwOnError: true });
        } catch (err: any) {
          if (err.message.includes("Could not refresh access token")) {
            document.location.href = "/login";
          }
        }
      }
    },
  });

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

  return children;
};

export default AuthMiddleware;

If the logged_in cookie is available then it means the user still has an access token. However, if React attempts to retrieve the logged_in cookie and an undefined value is returned then that means the access token has expired.

When the logged_in cookie is available, the getMe query will be evoked to retrieve the authenticated user’s profile information from the tRPC API. When the tRPC API returns a “must be logged in” error, React Query will call the refreshToken query to obtain a new access token.

After the access token has been refreshed, the .invalidateQueries([["getMe"]]) method will be evoked to invalidate the getMe query. This will force React Query to re-fetch the user’s credentials with the new access token.

Creating the Home and Profile Pages

Now let’s create the remaining React components. Here, you’ll create the Home and Profile pages. The Profile page will be private and only authenticated user’s can see it. The Home page on the other hand will be public and anyone can visit it.

Home Page

To create the Home page, go into the packages/client/src/pages folder and create a home.page.tsx file. After that, open the home.page.tsx file and add the code below.

packages/client/src/pages/home.page.tsx


const HomePage = () => {
  return (
    <section className='bg-ct-blue-600 min-h-screen pt-20'>
      <div className='max-w-4xl mx-auto bg-ct-dark-100 rounded-md h-[20rem] flex justify-center items-center'>
        <p className='text-5xl font-semibold'>Home Page</p>
      </div>
    </section>
  );
};

export default HomePage;

Profile Page

Let’s create the Profile page to display the user’s profile information. When this component mounts, the user’s credentials will be retrieved from the Zustand store and React will display them in the UI. So, create a profile.page.tsx file in the pages directory and add the code snippets below.

packages/client/src/pages/profile.page.tsx


import useStore from '../store';

const ProfilePage = () => {
  const store = useStore();

  const user = store.authUser;

  return (
    <section className='bg-ct-blue-600 min-h-screen pt-20'>
      <div className='max-w-4xl mx-auto bg-ct-dark-100 rounded-md h-[20rem] flex justify-center items-center'>
        <div>
          <p className='text-5xl font-semibold'>Profile Page</p>
          <div className='mt-8'>
            <p className='mb-4'>ID: {user?.id}</p>
            <p className='mb-4'>Name: {user?.name}</p>
            <p className='mb-4'>Email: {user?.email}</p>
            <p className='mb-4'>Role: {user?.role}</p>
          </div>
        </div>
      </div>
    </section>
  );
};

export default ProfilePage;

Setup Routing with React-Router-Dom

React Router DOM v6 ships with <Routes>, <Route>, and <Outlet> APIs for creating routes with React elements. But it also provides another API that can be used to declare routes with plain JavaScript objects.

Each JavaScript Object has a path, element, and optional children properties in the array of routes.

To define the routes, create a router folder in the packages/client/src directory. Then, create an index.tsx file in the router folder and add the following route definitions.

packages/client/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 authRoutes: RouteObject = {
  path: '*',
  children: [
    {
      path: 'login',
      element: <LoginPage />,
    },
    {
      path: 'register',
      element: <RegisterPage />,
    },
  ],
};

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

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

export default routes;

Update the Index and App Files

Now that we’ve created the routes, we need a way to render them as React elements. Luckily, React Router DOM v6 provides a first-class API hook called useRoutes that can be used to compose routes from JavaScript objects.

The useRoutes hook takes an array of JavaScript Objects as an argument and returns Route elements based on the matched location.

packages/client/src/App.tsx


import { useState } from "react";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useRoutes } from "react-router-dom";
import { getFetch, httpBatchLink, loggerLink } from "@trpc/client";
import routes from "./router";
import { trpc } from "./trpc";
import AuthMiddleware from "./middleware/AuthMiddleware";

function AppContent() {
  const content = useRoutes(routes);
  return content;
}

function App() {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 1000,
          },
        },
      })
  );

  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink(),
        httpBatchLink({
          url: "http://localhost:8000/api/trpc",
          fetch: async (input, init?) => {
            const fetch = getFetch();
            return fetch(input, {
              ...init,
              credentials: "include",
            });
          },
        }),
      ],
    })
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <AuthMiddleware>
          <AppContent />
        </AuthMiddleware>
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

export default App;

Above, we created the React Query and tRPC clients and wrapped them around the AppContent component.

To make the routing work, wrap React Router Dom’s <BrowserRouter> element around the root App component. Also, include the ToastContainer component so that the alert notifications can work.

packages/client/src/index.tsx


import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import App from './App';

import './global.css';
import 'react-toastify/dist/ReactToastify.css';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <Router>
      <App />
      <ToastContainer />
    </Router>
  </React.StrictMode>
);

Conclusion

With this React Query, tRPC Client, tailwindCss, and React-Hook-Form example in Typescript, you’ve learned how to add access and refresh token functionality to your React.js applications.

React Query and tRPC Client Source Code

Check out the complete source code for: