tRPC popularly known as t3-stack is a toolkit for building and consuming end-to-end typesafe APIs without depending on defined schemas or extra libraries for code generation.

This article will teach you how to build a full-stack tRPC CRUD app to perform the Create/Update/Get/Delete operations with React.js, Express, and Node.js using a monolithic repository aka monorepo.

tRPC API 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
  4. Build Full-Stack tRPC CRUD Application with Node.js, and React.js

Read more articles:

Build Full-Stack tRPC CRUD Application with Node.js, and React.js

What is tRPC?

tRPC is a library introduced to solve the complexities of GraphQL and to streamline the process of building type-safety full-stack applications with TypeScript.

tRPC is a lot simpler and couples your server and client more tightly together by allowing you to easily share the TypeScript types between them.

TRPC React and Node CRUD App
TRPC React and Node CRUD App

Prerequisites

Before we start, you should:

  • be comfortable with JavaScript, TypeScript, and React.js
  • be comfortable with CSS and tailwindCss

React Query, tRPC Client and Server Overview

We will build a React.js, tailwindCss, and TypeScript client with React Query and tRPC Client to make CRUD operations against a tRPC API.

Below are the endpoints of the tRPC CRUD API:

ROUTEDESCRIPTION
/api/trpc/posts.getPostsRetrieve all posts
/api/trpc/posts.createCreate new post
/api/trpc/posts.getPostGet a single post
/api/trpc/posts.updateUpdate a post
/api/trpc/posts.deleteDelete a post

-A tRPC React Query GET request is made on the homepage to retrieve all posts

tRPC fullstack crud app fetch all post query 1

-To add a new post to the database, click on the “Create Post” button on the navigation menu to display the create post popup.

Next, provide the required information and make a tRPC POST request to the tRPC server for the post to be added to the database.

tRPC fullstack crud app create post mutation

-To update a post, click on the three dots adjacent to the author’s name and then click the edit button to display the update post popup.

tRPC fullstack crud app edit post overview

Here the fields will be automatically filled when the popup mounts. Edit the fields you want and make a tRPC POST request to update the post on the tRPC server.

tRPC fullstack crud app update post mutation

-To remove a post from the database, click on the three dots again and then click the delete button. You will be prompted to confirm your action before a tRPC POST request is made to the tRPC server to delete the post from the database.

tRPC fullstack crud app delete post mutation

Follow these articles to implement the tRPC server and the authentication aspect before continuing with this tutorial.

tRPC Client and React Query Project Setup

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

After the project setup, change the directory into the client folder cd packages/client and run the following code to install the AppRouter type we exported from the tRPC server.


yarn add server@1.0.0

Where:

  • server – is the name used in the packages/server/package.json file.
  • @1.0.0 – is the version of the packages/server/package.json file.

Creating the TypeScript Types

packages/client/src/lib/types.ts


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

export type IPost = {
  _id: string;
  id: string;
  title: string;
  content: string;
  category: string;
  image: string;
  createdAt: string;
  updatedAt: string;
  user: {
    email: string;
    name: string;
    photo: string;
  };
};

Create a Modal Component with TailwindCSS

packages/client/src/components/modals/post.modal.tsx


import ReactDom from 'react-dom';
import React, { FC } from 'react';

type IPostModal = {
  openPostModal: boolean;
  setOpenPostModal: (openPostModal: boolean) => void;
  children: React.ReactNode;
};

const PostModal: FC<IPostModal> = ({
  openPostModal,
  setOpenPostModal,
  children,
}) => {
  if (!openPostModal) return null;
  return ReactDom.createPortal(
    <>
      <div
        className='fixed inset-0 bg-[rgba(0,0,0,.5)] z-[1000]'
        onClick={() => setOpenPostModal(false)}
      ></div>
      <div className='max-w-lg w-full rounded-md fixed top-[15%] left-1/2 -translate-x-1/2 bg-white z-[1001] p-6'>
        {children}
      </div>
    </>,
    document.getElementById('post-modal') as HTMLElement
  );
};

export default PostModal;

Next, add this line of HTML to the packages/client/public/index.html file.


<div id="post-modal"></div>
tRPC edit the html file

tRPC Client and React Query DELETE Request

Now change the directory into the packages/client folder in your terminal and run this command to install the date-fns library to help us to format the createdAt and updatedAt timestamps returned by the tRPC server.


yarn add date-fns

date-fns – is a library that provides utility functions for manipulating JavaScript dates in the browser and Node.js.

packages/client/src/components/posts/post.component.tsx


import React, { FC, useEffect, useState } from "react";
import { format, parseISO } from "date-fns";
import { twMerge } from "tailwind-merge";
import { IPost } from "../../lib/types";
import { toast } from "react-toastify";
import useStore from "../../store";
import PostModal from "../modals/post.modal";
import UpdatePost from "./update.post";
import { trpc } from "../../trpc";
import { useQueryClient } from "@tanstack/react-query";

type PostItemProps = {
  post: IPost;
};

const PostItem: FC<PostItemProps> = ({ post }) => {
  const [openMenu, setOpenMenu] = useState(false);
  const [openPostModal, setOpenPostModal] = useState(false);
  const store = useStore();
  const queryClient = useQueryClient();
  const { isLoading, mutate: deletePost } = trpc.deletePost.useMutation({
    onSuccess(data) {
      store.setPageLoading(false);
      queryClient.refetchQueries([["getPosts"]]);
      toast("Post deleted successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error: any) {
      store.setPageLoading(false);
      error.response.errors.forEach((err: any) => {
        toast(err.message, {
          type: "error",
          position: "top-right",
        });
      });
    },
  });

  useEffect(() => {
    if (isLoading) {
      store.setPageLoading(true);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

  const toggleMenu = () => {
    setOpenMenu(!openMenu);
  };

  const onDeleteHandler = (id: string) => {
    toggleMenu();
    if (window.confirm("Are you sure")) {
      deletePost({ postId: id });
    }
  };
  return (
    <>
      <div
        className="rounded-md shadow-md bg-white"
        onClick={() => toggleMenu()}
      >
        <div className="mx-2 mt-2 overflow-hidden rounded-md">
          <img
            src={post.image}
            alt={post.title}
            className="object-fill w-full h-full"
          />
        </div>
        <div className="p-4">
          <h5 className="font-semibold text-xl text-[#4d4d4d] mb-4">
            {post.title.length > 25
              ? post.title.substring(0, 25) + "..."
              : post.title}
          </h5>
          <div className="flex items-center mt-4">
            <p className="p-1 rounded-sm mr-4 bg-[#dad8d8]">{post.category}</p>
            <p className="text-[#ffa238]">
              {format(parseISO(post.createdAt), "PPP")}
            </p>
          </div>
        </div>
        <div className="flex justify-between items-center px-4 pb-4">
          <div className="flex items-center">
            <div className="w-12 h-12 rounded-full overflow-hidden">
              <img
                src={post.user.photo}
                alt={post.user.name}
                className="object-cover w-full h-full"
              />
            </div>
            <p className="ml-4 text-sm font-semibold">{post.user.name}</p>
          </div>
          <div className="relative">
            <div
              className="text-3xl text-[#4d4d4d] cursor-pointer p-3"
              onClick={toggleMenu}
            >
              <i className="bx bx-dots-horizontal-rounded"></i>
            </div>
            <ul
              className={twMerge(
                `absolute bottom-5 -right-1 z-50 py-2 rounded-sm bg-white shadow-lg transition ease-out duration-300 invisible`,
                `${openMenu ? "visible" : "invisible"}`
              )}
            >
              <li
                className="w-24 h-7 py-3 px-2 hover:bg-[#f5f5f5] flex items-center gap-2 cursor-pointer transition ease-in duration-300"
                onClick={() => {
                  setOpenPostModal(true);
                  toggleMenu();
                }}
              >
                <i className="bx bx-edit-alt"></i> <span>Edit</span>
              </li>
              <li
                className="w-24 h-7 py-3 px-2 hover:bg-[#f5f5f5] flex items-center gap-2 cursor-pointer transition ease-in duration-300"
                onClick={() => onDeleteHandler(post._id)}
              >
                <i className="bx bx-trash"></i> <span>Delete</span>
              </li>
            </ul>
          </div>
        </div>
      </div>
      <PostModal
        openPostModal={openPostModal}
        setOpenPostModal={setOpenPostModal}
      >
        <UpdatePost post={post} setOpenPostModal={setOpenPostModal} />
      </PostModal>
    </>
  );
};

export default PostItem;

tRPC Client and React Query GET Request

packages/client/src/components/Message.tsx


import React, { FC } from 'react';

type IMessageProps = {
  children: React.ReactNode;
};
const Message: FC<IMessageProps> = ({ children }) => {
  return (
    <div
      className='max-w-3xl mx-auto rounded-lg px-4 py-3 shadow-md bg-teal-100 flex items-center justify-center h-40'
      role='alert'
    >
      <span className='text-teal-500 text-xl font-semibold'>{children}</span>
    </div>
  );
};

export default Message;

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


import { useEffect } from "react";
import { useCookies } from "react-cookie";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import Message from "../components/Message";
import PostItem from "../components/posts/post.component";
import useStore from "../store";
import { trpc } from "../trpc";

const HomePage = () => {
  const [cookies] = useCookies(["logged_in"]);
  const store = useStore();
  const navigate = useNavigate();
  const { data: posts } = trpc.getPosts.useQuery(
    { limit: 10, page: 1 },
    {
      select: (data) => data.data.posts,
      retry: 1,
      onSuccess: (data) => {
        store.setPageLoading(false);
      },
      onError(error: any) {
        store.setPageLoading(false);
        toast(error.message, {
          type: "error",
          position: "top-right",
        });
      },
    }
  );

  useEffect(() => {
    if (!cookies.logged_in) {
      navigate("/login");
    }
  }, [cookies.logged_in, navigate]);

  return (
    <>
      <section className="bg-ct-blue-600 min-h-screen py-12">
        <div>
          {posts?.length === 0 ? (
            <Message>There are no posts at the moment</Message>
          ) : (
            <div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-5 px-6">
              {posts?.map((post: any) => (
                <PostItem key={post._id} post={post} />
              ))}
            </div>
          )}
        </div>
      </section>
    </>
  );
};

export default HomePage;

tRPC and React Query CREATE Request

packages/client/src/components/posts/create.post.tsx


import { FC, useEffect } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import FileUpLoader from "../FileUpload";
import { LoadingButton } from "../LoadingButton";
import TextInput from "../TextInput";
import { toast } from "react-toastify";
import useStore from "../../store";
import { trpc } from "../../trpc";
import { useQueryClient } from "@tanstack/react-query";

const createPostSchema = object({
  title: string().min(1, "Title is required"),
  category: string().min(1, "Category is required"),
  content: string().min(1, "Content is required"),
  image: string().min(1, "Image is required"),
});

type CreatePostInput = TypeOf<typeof createPostSchema>;

type ICreatePostProp = {
  setOpenPostModal: (openPostModal: boolean) => void;
};

const CreatePost: FC<ICreatePostProp> = ({ setOpenPostModal }) => {
  const store = useStore();
  const queryClient = useQueryClient();
  const { isLoading, mutate: createPost } = trpc.createPost.useMutation({
    onSuccess(data) {
      store.setPageLoading(false);
      setOpenPostModal(false);
      queryClient.refetchQueries([["getPosts"]]);
      toast("Post created successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error: any) {
      store.setPageLoading(false);
      setOpenPostModal(false);
      error.response.errors.forEach((err: any) => {
        toast(err.message, {
          type: "error",
          position: "top-right",
        });
      });
    },
  });
  const methods = useForm<CreatePostInput>({
    resolver: zodResolver(createPostSchema),
  });

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = methods;

  useEffect(() => {
    if (isLoading) {
      store.setPageLoading(true);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

  const onSubmitHandler: SubmitHandler<CreatePostInput> = async (data) => {
    createPost(data);
  };
  return (
    <section>
      <h2 className="text-2xl font-semibold mb-4">Create Post</h2>

      <FormProvider {...methods}>
        <form className="w-full" onSubmit={handleSubmit(onSubmitHandler)}>
          <TextInput name="title" label="Title" />
          <TextInput name="category" label="Category" />
          <div className="mb-2">
            <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
              Content
            </label>
            <textarea
              className={twMerge(
                `appearance-none border border-ct-dark-200 rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
                `${errors.content && "border-red-500"}`
              )}
              rows={4}
              {...register("content")}
            />
            <p
              className={twMerge(
                `text-red-500 text-xs italic mb-2 invisible`,
                `${errors.content && "visible"}`
              )}
            >
              {errors.content ? errors.content.message : ""}
            </p>
          </div>
          <FileUpLoader name="image" />
          <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
            Create Post
          </LoadingButton>
        </form>
      </FormProvider>
    </section>
  );
};

export default CreatePost;

tRPC and React Query UPDATE Request

packages/client/src/components/posts/update.post.tsx


import React, { FC, useEffect } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import FileUpLoader from "../FileUpload";
import { LoadingButton } from "../LoadingButton";
import TextInput from "../TextInput";
import { toast } from "react-toastify";
import useStore from "../../store";
import { IPost } from "../../lib/types";
import { trpc } from "../../trpc";
import { useQueryClient } from "@tanstack/react-query";

type IUpdatePostProps = {
  post: IPost;
  setOpenPostModal: (openPostModal: boolean) => void;
};

const updatePostSchema = object({
  title: string().min(1, "Title is required"),
  category: string().min(1, "Category is required"),
  content: string().min(1, "Content is required"),
  image: string().min(1, "Image is required"),
});

type UpdatePostInput = TypeOf<typeof updatePostSchema>;

const UpdatePost: FC<IUpdatePostProps> = ({ post, setOpenPostModal }) => {
  const queryClient = useQueryClient();
  const store = useStore();
  const { isLoading, mutate: updatePost } = trpc.updatePost.useMutation({
    onSuccess(data) {
      store.setPageLoading(false);
      setOpenPostModal(false);
      queryClient.refetchQueries([["getPosts"]]);
      toast("Post updated successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error: any) {
      store.setPageLoading(false);
      setOpenPostModal(false);
      error.response.errors.forEach((err: any) => {
        toast(err.message, {
          type: "error",
          position: "top-right",
        });
      });
    },
  });
  const methods = useForm<UpdatePostInput>({
    resolver: zodResolver(updatePostSchema),
  });

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = methods;

  useEffect(() => {
    if (isLoading) {
      store.setPageLoading(true);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

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

  const onSubmitHandler: SubmitHandler<UpdatePostInput> = async (data) => {
    updatePost({ body: data, params: { postId: post._id } });
  };
  return (
    <section>
      <h2 className="text-2xl font-semibold mb-4">Update Post</h2>
      <FormProvider {...methods}>
        <form className="w-full" onSubmit={handleSubmit(onSubmitHandler)}>
          <TextInput name="title" label="Title" />
          <TextInput name="category" label="Category" />
          <div className="mb-2">
            <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
              Content
            </label>
            <textarea
              className={twMerge(
                `appearance-none border border-ct-dark-200 rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
                `${errors.content && "border-red-500"}`
              )}
              rows={4}
              {...register("content")}
            />
            <p
              className={twMerge(
                `text-red-500 text-xs italic mb-2 invisible`,
                `${errors.content && "visible"}`
              )}
            >
              {errors.content ? errors.content.message : ""}
            </p>
          </div>
          <FileUpLoader name="image" />
          <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
            Update Post
          </LoadingButton>
        </form>
      </FormProvider>
    </section>
  );
};

export default UpdatePost;

Conclusion

With this React Query, tRPC Client, tailwindCss, React-Hook-Form, and Zod example in TypeScript, you’ve learned how to perform CRUD (Create, Read, Update, and Delete) operations against a tRPC API.

tRPC Full-Stack App Source Code

You can find the complete source code on my GitHub.