tRPC is a remote procedure call framework used to build full-stack TypeScript applications with a minimal footprint.

Usually, when we build full-stack applications that leverage TypeScript, sharing types between the API boundaries and web app becomes challenging.

You may already be familiar with GraphQL which solved most of the flaws of REST (REpresentational State Transfer) architecture by providing tools to build type-safe full-stack applications.

While GraphQL was the dominant library for building type-safe applications, it came at a cost in the form of extra complexities.

Full-Stack Next.js tRPC App User Re...
Full-Stack Next.js tRPC App User Registration & Login Example

Some of the flaws of GraphQL include:

  • It depends solely on schemas defined on the backend
  • You have to install extra dependencies to generate the TypeScript types, mutations, queries, and subscriptions on the client.
  • Since GraphQL is a query language, it requires developers to learn the language first before building projects with it.

tRPC on the other hand provides a type-safe way to build APIs with TypeScript and infer the type from the functions defined on the server.

In this article, we’ll build a full-stack Next.js tRPC app to register and sign in a user using React Query, TypeScript, React-Hook-Form, and Zod.

This project will teach you how to make tRPC requests to the tRPC API we built in the previous article to:

  • Signup new users
  • Sign in the registered user
  • Refresh the access token
  • Fetch the currently authenticated user’s profile information
  • Logout the user

tRPC API Server and Client with Next.js, PostgreSQL, and Prisma Series

  1. How to Setup tRPC API Server & Client with Next.js and Prisma
  2. tRPC Server API with Next.js & PostgreSQL: Access & Refresh Tokens
  3. Full-Stack Next.js tRPC App: User Registration & Login Example

Related articles:

Full-Stack Next.js tRPC App User Registration & Login Example

Prerequisites

Before going further with this article, you should:

  • Have basic knowledge of React.js, TypeScript, and Next.js
  • Be comfortable with CSS and tailwindCss

Next.js tRPC API Server and Client Overview

These are the tRPC endpoints we will be making the requests to:

MethodsEndpointsDescriptions
POSTapi/trpc/auth.loginLogin the user
POSTapi/trpc/auth.registerRegister the user
POSTapi/trpc/auth.logoutLogout the user
GETapi/trpc/auth.refreshRefresh the access token
GETapi/trpc/users.meFetch the user’s credentials
GETapi/trpc/helloWelcome message

On the homepage, the “hello” tRPC function is evoked to return the message stored in the Redis database.

To create a new account, the user will click on the “SignUp” link on the navigation menu to display the registration page.

nextjs tRPC client and server fullstack app home page

The user then provides the necessary credentials and clicks on the “Sign Up” button to evoke the api/trpc/auth.register tRPC procedure function.

The tRPC API server receives the request, validates the credentials, adds the user to the PostgreSQL database, and returns a success message to the tRPC client.

nextjs tRPC client and server fullstack app register a new user

Next.js redirects the user to the login page upon receiving the success message from the server.

Now the user provides the credentials used in registering for the account and evokes the api/trpc/auth.login tRPC procedure function to sign into the account.

nextjs tRPC client and server fullstack app login user overview page

The tRPC server validates the login credentials, creates a session for the user in the Redis database, and returns a success message to the tRPC client.

Next.js then redirects the user to the profile page where his profile information is displayed.

nextjs tRPC client and server fullstack app profile page

To see the cookies returned by the tRPC server, open the dev tools in your browser and click on the Application tab.

Next, click on the Cookies dropdown and select the Next.js URL from the list.

jwt refresh access token cookies in the browser

Step 1 – Setup the Next.js tRPC Server and Client

If you landed on this article from a Google search then you need to catch up by following these articles:

The Setup tRPC API Server & Client article will help you set up the tRPC API server with Next.js, tailwindCss, React Query, Prisma, and PostgreSQL.

Also, the tRPC Server API with Next.js Access and Refresh Tokens article will help you build the tRPC API to register the user, refresh the access token, sign in the user, and log out the user.

Step 2 – Add the Zustand State Management Library

While Redux is undoubtedly the most popular React state management library, it requires a lot of code to get it up and running in a project. One major downside is you have to create actions, dispatch functions, and reducers to be able to update a single state.

With all these flaws of Redux, the Zustand library was introduced to eliminate the complexities involved with Redux and make client-side state management a breeze.

Zustand allows you to easily create a global state with few lines of code and inject it into any component in the DOM tree.

Also, Zustand allows you to retrieve data from the state in functions that are not Next.js/React.js components.

To begin, create a src/client/lib/types.ts file and add the following TypeScript type.

src/client/lib/types.ts


export type IUser = {
  _id: string;
  id: string;
  email: string;
  name: string;
  role: string;
  photo: string;
  updatedAt: string;
  createdAt: string;
};


Install the Zustand library with this command:


npm install zustand
# or
yarn add zustand

Now create a src/client/store/index.ts file and add these codes to create the Zustand store.

src/client/store/index.ts


import create from 'zustand';
import { IUser } from '../lib/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;

Step 3 – Create Reusable Next.js Components

In this section, you will create some reusable Next.js components with Tailwind CSS.

Create a Loading Spinner Component


yarn add tailwind-merge
# or
npm install tailwind-merge

src/client/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 Loading Button Component

src/client/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 inline-block'>Loading...</span>
        </div>
      ) : (
        <span className={`${textColor}`}>{children}</span>
      )}
    </button>
  );
};

Create a FullScreen Loader Component

src/client/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 the tRPC Client Header Component


yarn add react-toastify
# or 
npm install react-toastify

src/client/components/Header.tsx


import Link from 'next/link';
import { useQueryClient } from 'react-query';
import { toast } from 'react-toastify';
import useStore from '../store';
import { trpc } from '../utils/trpc';
import Spinner from './Spinner';

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

  const queryClient = useQueryClient();
  const { mutate: logoutUser } = trpc.useMutation(['auth.logout'], {
    onSuccess(data) {
      queryClient.clear();
      document.location.href = '/login';
    },
    onError(error: any) {
      error.response.errors.forEach((err: any) => {
        toast(err.message, {
          type: 'error',
          position: 'top-right',
        });
        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 href='/' className='text-ct-dark-600 text-2xl font-semibold'>
              CodevoWeb
            </Link>
          </div>
          <ul className='flex items-center gap-4'>
            <li>
              <Link href='/' className='text-ct-dark-600'>
                Home
              </Link>
            </li>
            {!user && (
              <>
                <li>
                  <Link href='/register' className='text-ct-dark-600'>
                    SignUp
                  </Link>
                </li>
                <li>
                  <Link href='/login' className='text-ct-dark-600'>
                    Login
                  </Link>
                </li>
              </>
            )}
            {user && (
              <>
                <li>
                  <Link href='/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>
      <div className='pt-4 pl-2 bg-ct-blue-600 fixed'>
        {store.pageLoading && <Spinner color='text-ct-yellow-600' />}
      </div>
    </>
  );
};

export default Header;

Create an InputField Component with React-Hook-Form


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

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

src/client/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 unknown as string}
        </span>
      )}
    </div>
  );
};

export default FormInput;

Create an Image Uploader Component

In this section, you will create an image uploader component to upload images to Cloudinary.

To get started, create a Cloudinary account and follow the steps below to configure the upload preset on your account.

Step 1: Click on the gear icon on the top navigation bar to display the settings page.

cloudinary dashboard

Step 2: On the settings page, click the “Upload” tab.

cloudinary dashboard upload tab

Step 3: Scroll down to the Upload presets area and click the Add upload preset link.

cloudinary dashboard upload settings

Step 4: Provide the Upload preset name and change the Signing Mode to Unsigned. Using an Unsigned mode will allow us to upload the images using the Upload API.

Now use the preset name as the folder name and click on the Upload Manipulations tab.

cloudinary dashboard upload settings preset

Step 5: Click on the Edit link below the Incoming Transformation section to display the Edit Transformation modal.

cloudinary dashboard add upload preset

Step 6: Change the image Width and Height to a reasonable size.

Next, change the image quality type from Manual to Automatic – best quality and click on the ok button.

cloudinary dashboard traform image

Step 7: Click on the Save button to commit the changes.

cloudinary dashboard click on the save button

To avoid unsolicited upload of images to your account, you need to keep your upload preset name a secret.

Now create a src/client/components/FileUpload.tsx file and add the following code to upload the images to Cloudinary.

src/client/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 = 'nextjs-trpc';
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 unknown as string)}
          </p>
        </>
      )}
    />
  );
};

export default FileUpLoader;

To upload the image to Cloudinary, you need to make a POST request with the formData as the payload.

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

You need to replace the :cloud_name with your Cloudinary cloud name and append the preset name to the formData.

Step 4 – Create the tRPC Client to Register a User

src/pages/register.tsx


import type { GetServerSideProps, NextPage } from "next";
import { object, string, TypeOf, z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
import { useEffect } from "react";
import FormInput from "../client/components/FormInput";
import Link from "next/link";
import { LoadingButton } from "../client/components/LoadingButton";
import { useRouter } from "next/router";
import { toast } from "react-toastify";
import FileUpLoader from "../client/components/FileUpload";
import { trpc } from "../client/utils/trpc";

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"),
  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: NextPage = () => {
  const router = useRouter();
  const { mutate: SignUpUser, isLoading } = trpc.registerUser.useMutation({
    onSuccess(data) {
      toast(`Welcome ${data.data.user.name}!`, {
        type: "success",
        position: "top-right",
      });
      console.log(data.data.user);
      router.push("/login");
    },
    onError(error) {
      toast(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 href="/login">
                <a className="text-ct-blue-600">Login Here</a>
              </Link>
            </span>
            <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
              Sign Up
            </LoadingButton>
          </form>
        </FormProvider>
      </div>
    </section>
  );
};

export const getServerSideProps: GetServerSideProps = async () => {
  return {
    props: {
      requireAuth: false,
      enableAuth: false,
    },
  };
};

export default RegisterPage;

Step 5 – Create the tRPC Client to Sign in the User

src/pages/login.tsx


import type { GetServerSideProps, NextPage } from "next";
import { object, string, TypeOf } from "zod";
import { useEffect } from "react";
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import FormInput from "../client/components/FormInput";
import { LoadingButton } from "../client/components/LoadingButton";
import Link from "next/link";
import { toast } from "react-toastify";
import { useRouter } from "next/router";
import useStore from "../client/store";
import { IUser } from "../client/lib/types";
import { trpc } from "../client/utils/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: NextPage = () => {
  const router = useRouter();
  const store = useStore();

  const query = trpc.getMe.useQuery(undefined, {
    enabled: false,
    onSuccess: (data) => {
      store.setAuthUser(data.data.user as unknown as IUser);
    },
  });

  const { isLoading, mutate: loginUser } = trpc.loginUser.useMutation({
    onSuccess(data) {
      toast("Logged in successfully", {
        type: "success",
        position: "top-right",
      });
      query.refetch();
      router.push("/profile");
    },
    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 href="#" className="">
                Forgot Password?
              </Link>
            </div>
            <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
              Login
            </LoadingButton>
            <span className="block">
              Need an account?{" "}
              <Link href="/register">
                <a className="text-ct-blue-600">Sign Up Here</a>
              </Link>
            </span>
          </form>
        </FormProvider>
      </div>
    </section>
  );
};

export const getServerSideProps: GetServerSideProps = async () => {
  return {
    props: {
      requireAuth: false,
      enableAuth: false,
    },
  };
};

export default LoginPage;

Step 6 – Create the tRPC Authentication Guard

src/client/middleware/AuthMiddleware.tsx


import React, { useEffect } from "react";
import { IUser } from "../lib/types";
import useStore from "../store";
import { trpc } from "../utils/trpc";
import { useQueryClient } from "@tanstack/react-query";

type AuthMiddlewareProps = {
  children: React.ReactNode;
  requireAuth?: boolean;
  enableAuth?: boolean;
};

const AuthMiddleware: React.FC<AuthMiddlewareProps> = ({
  children,
  requireAuth,
  enableAuth,
}) => {
  console.log("I was called from AuthMiddleware");
  const store = useStore();
  const queryClient = useQueryClient();
  const query = trpc.refreshAccessToken.useQuery(undefined, {
    enabled: false,
    retry: 1,
    onError(error: any) {
      store.setPageLoading(false);
      document.location.href = "/login";
    },
    onSuccess(data: any) {
      store.setPageLoading(false);
      queryClient.refetchQueries(["users.me"]);
    },
  });
  const { isLoading, isFetching } = trpc.getMe.useQuery(undefined, {
    onSuccess: (data) => {
      store.setPageLoading(false);
      store.setAuthUser(data.data.user as unknown as IUser);
    },
    retry: 1,
    enabled: !!enableAuth,
    onError(error) {
      store.setPageLoading(false);
      if (error.message.includes("must be logged in")) {
        query.refetch({ throwOnError: true });
      }
    },
  });

  return <>{children}</>;
};

export default AuthMiddleware;

Step 7 – Create the Remaining Next.js Pages

Home Page

src/pages/index.tsx


import type { NextPage } from "next";
import Header from "../client/components/Header";
import { trpc } from "../client/utils/trpc";

export const getServerSideProps = async () => {
  return {
    props: {
      requireAuth: false,
      enableAuth: false,
    },
  };
};

const HomePage: NextPage = () => {
  const { data, isLoading, isFetching, error, isError } =
    trpc.getHello.useQuery();

  return (
    <>
      <Header />
      <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-3xl font-semibold">{data?.message}</p>
        </div>
      </section>
    </>
  );
};

export default HomePage;

Profile Page

src/pages/profile.tsx


import type { GetServerSideProps, NextPage } from "next";
import Header from "../client/components/Header";
import { IUser } from "../client/lib/types";
import useStore from "../client/store";
import { trpc } from "../client/utils/trpc";

type ProfileProps = {};

const ProfilePage: NextPage<ProfileProps> = ({}) => {
  const store = useStore();

  const user = store.authUser;
  const query = trpc.getMe.useQuery(undefined, {
    retry: 1,
    onSuccess: (data) => {
      store.setAuthUser(data.data.user as unknown as IUser);
    },
  });

  return (
    <>
      <Header />
      <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 const getServerSideProps: GetServerSideProps = async ({ req }) => {
  if (!req.cookies.access_token) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }

  return {
    props: {
      requireAuth: true,
      enableAuth: true,
    },
  };
};

export default ProfilePage;

Step 8 – Update the App File

src/pages/_app.tsx


import "../styles/globals.css";
import "react-toastify/dist/ReactToastify.css";
import type { AppProps } from "next/app";
import { ToastContainer } from "react-toastify";
import { CookiesProvider } from "react-cookie";
import AuthMiddleware from "../client/middleware/AuthMiddleware";
import { trpc } from "~/client/utils/trpc";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <CookiesProvider>
      <AuthMiddleware
        requireAuth={pageProps.requireAuth}
        enableAuth={pageProps.enableAuth}
      >
        <ToastContainer />
        <Component {...pageProps} />
        <ReactQueryDevtools initialIsOpen={false} />
      </AuthMiddleware>
    </CookiesProvider>
  );
}

export default trpc.withTRPC(MyApp);

Conclusion

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

Full-Stack Next.js tRPC App Source Code

Check out the complete source code for: