In this guide, you’ll create a full-stack note application that follows the CRUD (Create, Read, Update, and Delete) architecture with tRPC and use Prisma ORM to store data in an SQLite database. We’ll build the tRPC API with Node.js and the UI with React.js.

What is tRPC? TypeScript Remote Procedure Call, also commonly referred to as tRPC, is a comprehensive framework that allows developers to build end-to-end typesafe full-stack applications using only TypeScript code.

Before the introduction of tRPC, GraphQL was the dominant language and server-side runtime for building full-stack type-safe applications, however, developers had to familiarise themselves with the language before building projects with it.

Due to this reason, libraries and services were introduced to provide an abstraction over the query language but this also made the process cumbersome for new developers.

With these flaws of GraphQL, tRPC was released to bring the good parts of GraphQL into REST architecture to make building type-safe applications easier for developers.

More practice:

  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
  4. Build a tRPC CRUD API Example with Next.js
Build a FullStack tRPC CRUD App with TypeScript

Prerequisites

Even though the tutorial is designed for beginners, these prerequisites are required to get the most out of the tutorial.

  • You should have basic knowledge of JavaScript, TypeScript, and Tailwind CSS.
  • You should have Node.js installed on your machine.
  • You should have some understanding of API designs and CRUD architecture.

Run the tRPC App Locally

  • Download or clone the tRPC project from https://github.com/wpcodevo/node-react-trpc-crud-app and open it with an IDE.
  • Install the client, server, and workspace dependencies by running yarn install or yarn in the terminal of the root project.
  • Change the directory into the server folder with cd packages/server and run yarn db:migrate && yarn db:push to migrate the schema to the SQLite database.
  • Change the directory back into the root project with cd ../.. and start the Node.js tRPC API and React UI by running yarn start .
  • Open a new tab in the browser and visit http://localhost:3000 to interact with the tRPC app. Note: Do not visit the site on http://127.0.0.1:3000 to avoid site can’t be reached or CORS errors.

Setup the tRPC Project

We’ll utilize a Monolithic repository that uses Yarn Workspaces to set up the tRPC API and UI with React.js, Express, Prisma, and SQLite. The tRPC API will be in a server folder and the tRPC client will be in a client folder.

trpc project structure with Node.js and React.js

To start both the server and client, we will use the concurrently package in conjunction with the wsrun package to run both dev servers in parallel.

With that out of the way, navigate to a location where you want to create the project and create a folder named trpc-crud-app . Feel free to use any name that suits your use case.

After that, open the project with an IDE or text editor. Create a package.json file in the root workspace and add the following JSON code.

package.json


{
  "name": "trpc-crud-app",
  "private": "true",
  "scripts": {},
  "workspaces": [
    "packages/*"
  ]
}

  • "private": "true" – prevents accidental publishing of the workspace
  • "packages/*" – Tells Yarn to recognize the directories in the “packages” folder as packages. Don’t worry we will create the “packages” folder in a bit.

Now let’s install the concurrently and wsrun packages by running the command below:


yarn add -W -D concurrently wsrun

  • -W – This flag tells Yarn to install the dependencies in the root workspace.
  • -D – This flat tells Yarn to install the packages as dev-dependencies.

Open the package.json file and replace its content with the following:

package.json


{
  "name": "trpc-crud-app",
  "private": "true",
  "scripts": {
    "start": "concurrently \"wsrun --parallel start\""
  },
  "workspaces": [
    "packages/*"
  ],
  "devDependencies": {
    "concurrently": "^7.5.0",
    "wsrun": "^5.2.4"
  }
}


Next, create a .gitignore file and add the node_modules to exclude it from the Git commit.

Create the tRPC API with Node.js

We are now ready to set up the tRPC API with Express and Node.js. To begin, create a “packages” folder that will hold both the API and UI. Within the packages folder, create a server folder.

mkdir -p packages/server

Next, change the directory into the packages/server folder and initialize the Node.js TypeScript project:


yarn init -y && yarn add -D typescript && npx tsc --init 

Open the packages/server/tsconfig.json file and replace its content with the following:


{
  "compilerOptions": {
    "target": "es2018",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "skipLibCheck": true
  }
}


The strict mode must be true since we will be using Zod to validate the incoming request payload.

Add Prisma ORM and SQLite

In this article, we will be storing data in an SQLite database but the code can easily be tweaked to work with any Prisma-supported database. Read How to Setup tRPC API with Prisma, PostgreSQL, Node & React to set up the project with PostgreSQL.

Let’s begin by installing the Prisma CLI and Client. To do that, make sure you are in the packages/server folder and run these commands:


yarn add -D prisma && yarn add @prisma/client

After that initialize the Prisma project with the init command:


npx prisma init --datasource-provider sqlite

Out of the box, Prisma uses PostgreSQL as the default database but providing --datasource-provider sqlite will tell Prisma to use SQLite.

Open the newly-generated packages/server/prisma/schema.prisma file and replace its content with the following schema.

packages/server/prisma/schema.prisma


generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Note {
  id        String   @id @default(uuid())
  title     String   @unique
  content   String
  category  String?
  published Boolean? @default(false)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map(name: "notes")
}


Quite a lot is going on in the above, let’s break it down:

  • First, we created a Note model and used the @@map(name: "notes") attribute to change the table name to “notes“.
  • Then, we defined the attributes needed to create a new note in the database.
  • Finally, we added a unique constraint on the title column to ensure that no two records end up with the same title in the database.

Open the packages/server/package.json file and add the following scripts:

packages/server/package.json


{
"scripts": {
    "db:migrate": "npx prisma migrate dev --name note-entity --create-only && yarn prisma generate",
    "db:push": "npx prisma db push"
  }
}

  • db:migrate – This will create the migration file and generate the Prisma client.
  • db:push – This will push the schema to the SQLite database

Now let’s create the notes table in the SQLite database and the Prisma Client by running the following commands:


yarn db:migrate && yarn db:push

After that, run this command to see the note table added by Prisma in the Prisma Studio.


npx prisma studio

Create Zod Validation Schemas

Before creating the tRPC procedures, let’s define validation schemas that the tRPC framework will use to validate the incoming request payloads.

Install the Zod library


yarn add zod

Next, create a packages/server/src/note.schema.ts file and add the following validation schemas.

packages/server/src/note.schema.ts


import { z } from "zod";

export const createNoteSchema = z.object({
  title: z.string({
    required_error: "Title is required",
  }),
  content: z.string({
    required_error: "Content is required",
  }),
  category: z.string().optional(),
  published: z.boolean().optional(),
});

export const params = z.object({
  noteId: z.string(),
});

export const updateNoteSchema = z.object({
  params,
  body: z
    .object({
      title: z.string(),
      content: z.string(),
      category: z.string(),
      published: z.boolean(),
    })
    .partial(),
});

export const filterQuery = z.object({
  limit: z.number().default(1),
  page: z.number().default(10),
});

export type ParamsInput = z.TypeOf<typeof params>;
export type FilterQueryInput = z.TypeOf<typeof filterQuery>;
export type CreateNoteInput = z.TypeOf<typeof createNoteSchema>;
export type UpdateNoteInput = z.TypeOf<typeof updateNoteSchema>;

We created four validation schemas and exported their TypeScript types with the help of Zod’s TypeOf<> type.

Create the tRPC Procedures

With the validation schemas defined, let’s create five tRPC procedures that will be used to perform the CRUD operations.

  • createNoteController – This RPC will be evoked to add a new note to the database.
  • updateNoteController – This RPC will be evoked to update an existing note in the database.
  • findNoteController – This RPC will be evoked to retrieve a single note.
  • findAllNotesController – This RPC will be evoked to retrieve all the notes or a paginated list of them.
  • deleteNoteController – This RPC will be evoked to remove a note from the database.

To get started, install the tRPC server package:


yarn add @trpc/server@next

After that, create a packages/server/src/note.controller.ts file and add the following procedures.

packages/server/src/note.controller.ts


import { Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { prisma } from "./app";
import {
  CreateNoteInput,
  FilterQueryInput,
  ParamsInput,
  UpdateNoteInput,
} from "./note.schema";

export const createNoteController = async ({
  input,
}: {
  input: CreateNoteInput;
}) => {
  try {
    const note = await prisma.note.create({
      data: {
        title: input.title,
        content: input.content,
        category: input.category,
        published: input.published,
      },
    });

    return {
      status: "success",
      data: {
        note,
      },
    };
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === "P2002") {
        throw new TRPCError({
          code: "CONFLICT",
          message: "Note with that title already exists",
        });
      }
    }
    throw error;
  }
};

export const updateNoteController = async ({
  paramsInput,
  input,
}: {
  paramsInput: ParamsInput;
  input: UpdateNoteInput["body"];
}) => {
  try {
    const updatedNote = await prisma.note.update({
      where: { id: paramsInput.noteId },
      data: input,
    });

    return {
      status: "success",
      note: updatedNote,
    };
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === "P2025") {
        throw new TRPCError({
          code: "CONFLICT",
          message: "Note with that title already exists",
        });
      }
    }
    throw error;
  }
};

export const findNoteController = async ({
  paramsInput,
}: {
  paramsInput: ParamsInput;
}) => {
  try {
    const note = await prisma.note.findFirst({
      where: { id: paramsInput.noteId },
    });

    if (!note) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "Note with that ID not found",
      });
    }

    return {
      status: "success",
      note,
    };
  } catch (error) {
    console.log(error);
    throw error;
  }
};

export const findAllNotesController = async ({
  filterQuery,
}: {
  filterQuery: FilterQueryInput;
}) => {
  try {
    const page = filterQuery.page || 1;
    const limit = filterQuery.limit || 10;
    const skip = (page - 1) * limit;

    const notes = await prisma.note.findMany({ skip, take: limit });

    return {
      status: "success",
      results: notes.length,
      notes,
    };
  } catch (error) {
    throw error;
  }
};

export const deleteNoteController = async ({
  paramsInput,
}: {
  paramsInput: ParamsInput;
}) => {
  try {
    console.log(paramsInput.noteId);
    await prisma.note.delete({ where: { id: paramsInput.noteId } });

    return {
      status: "success",
    };
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === "P2025") {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Note with that ID not found",
        });
      }
    }
    throw error;
  }
};

Setup the Express and tRPC Servers

Now that we have all the RPC procedures defined, let’s create the tRPC router to evoke them. Since the tRPC API will run on an Express.js server, open your terminal and install these dependencies:


yarn add express cors && yarn add -D morgan ts-node-dev @types/cors @types/express @types/morgan @types/node

  • cors – Will configure the server to accept requests from cross-origin domains.
  • express – A Node.js web framework
  • ts-node-dev – Hot-reload the server upon every file change
  • morgan – Log the requests in the terminal

After all the dependencies have been installed, create a packages/server/src/app.ts file and the following code.

packages/server/src/app.ts


import express from "express";
import morgan from "morgan";
import cors from "cors";
import { initTRPC } from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express";
import { PrismaClient } from "@prisma/client";
import {
  createNoteSchema,
  filterQuery,
  params,
  updateNoteSchema,
} from "./note.schema";
import {
  createNoteController,
  deleteNoteController,
  findAllNotesController,
  findNoteController,
  updateNoteController,
} from "./note.controller";

export const prisma = new PrismaClient();
const t = initTRPC.create();

const appRouter = t.router({
  getHello: t.procedure.query((req) => {
    return { message: "Welcome to Full-Stack tRPC CRUD App" };
  }),
  createNote: t.procedure
    .input(createNoteSchema)
    .mutation(({ input }) => createNoteController({ input })),
  updateNote: t.procedure
    .input(updateNoteSchema)
    .mutation(({ input }) =>
      updateNoteController({ paramsInput: input.params, input: input.body })
    ),
  deleteNote: t.procedure
    .input(params)
    .mutation(({ input }) => deleteNoteController({ paramsInput: input })),
  getNote: t.procedure
    .input(params)
    .query(({ input }) => findNoteController({ paramsInput: input })),
  getNotes: t.procedure
    .input(filterQuery)
    .query(({ input }) => findAllNotesController({ filterQuery: input })),
});

export type AppRouter = typeof appRouter;

const app = express();
if (process.env.NODE_ENV !== "production") app.use(morgan("dev"));

app.use(
  cors({
    origin: ["http://localhost:3000"],
    credentials: true,
  })
);
app.use(
  "/api/trpc",
  trpcExpress.createExpressMiddleware({
    router: appRouter,
  })
);

const port = 8000;
app.listen(port, () => {
  console.log(`🚀 Server listening on port ${port}`);
});

Let’s evaluate the above code. At the top level, we imported all the dependencies, created an instance of the Prisma Client, and initialized the tRPC server.

After that, we created an appRouter to evoke all the RPC procedures, inferred all the TypeScript types from the router, and exported the AppRouter from the file.

Finally, we created an instance of the Express app, added the tRPC middleware to the Express.js middleware pipeline, and started the server by evoking the .listen() method.

Now open the packages/server/package.json file and add this start script:

packages/server/package.json


{
"scripts": {
    "start": "ts-node-dev --respawn --transpile-only src/app.ts"
  }
}

Here comes the most important part of this tutorial. Because the tRPC client needs access to the AppRouter type we exported from the server/src/app.ts file, let’s convert the tRPC API into a library so that we can install it on the client.

To do that, open the packages/server/package.json file and modify the following properties:

  1. Change the value of the “name” property to “api-server
  2. Change the value of the “main” property to “src/app.ts
change the name and main properties

Your packages/server/package.json file should now look like this:

packages/server/package.json


{
  "name": "api-server",
  "version": "1.0.0",
  "main": "src/app.ts",
  "license": "MIT",
  "scripts": {
    "start": "ts-node-dev --respawn --transpile-only src/app.ts",
    "db:migrate": "npx prisma migrate dev --name note-entity --create-only && yarn prisma generate",
    "db:push": "npx prisma db push"
  },
  "devDependencies": {
    "@types/cors": "^2.8.12",
    "@types/express": "^4.17.14",
    "@types/morgan": "^1.9.3",
    "@types/node": "^18.11.5",
    "morgan": "^1.10.0",
    "prisma": "^4.5.0",
    "ts-node-dev": "^2.0.0",
    "typescript": "^4.8.4"
  },
  "dependencies": {
    "@prisma/client": "^4.5.0",
    "@trpc/server": "^10.0.0-proxy-beta.26",
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "zod": "^3.19.1"
  }
}


Oops, quite a lot of code. If you’ve made it this far, am proud of you. To test the tRPC API, make sure you are in the terminal of the server folder and run yarn start to spin up the server on port 8000.

Next, open a new tab in your browser and visit http://localhost:8000/api/trpc/getHello to evoke the getHello procedure on the API.

testing the trpc api in the browser

Create the tRPC Client with React.js

Now that we have the server listening on port 8000, let’s set up the tRPC client with React.js. Open your terminal and change the directory into the “packages” folder.

In this project, we’ll use Vite‘s scaffolding tool to bootstrap the React.js app instead of using Create React App. To do that, make sure you are in the terminal of the “packages” directory and run yarn create vite .

This will install the Vite executable from the remote NPM repository and prompt you to enter the project name. Enter “client” as the project name, select “react” from the framework options, and choose “react-ts” as the variant.

After that change the directory into the client folder cd client and run yarn to install all the dependencies.

Next, open the packages/client/package.json file and modify the “dev” property in the scripts object to “start“. By default, Vite will start the development server on port 5173 but I always want my React app to run on port 3000.

packages/client/package.json


{
 "scripts": {
    "start": "vite --host localhost --port 3000",
    "build": "tsc && vite build",
    "preview": "vite preview"
  }
}

With that out of the way, let’s install the tRPC server as a library in the React project. In the terminal, make sure you are in the client folder and run this command:


yarn add api-server@1.0.0
  • api-server – Is the value of the “name” property in the server/package.json file.
  • @1.0.0 – Is the value of the “version” property in the server/package.json file.

Now install the tRPC client and its peer dependencies:


yarn add @trpc/client@next @trpc/server@next @trpc/react-query@next @tanstack/react-query @tanstack/react-query-devtools

Setup the tRPC Client

Now that we have all the dependencies installed, create a packages/client/src/utils/trpc.ts file and add the following code snippets:

packages/client/src/utils/trpc.ts


import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "api-server";
export const trpc = createTRPCReact<AppRouter>();

This will generate the React Query hooks based on the queries and mutations defined on the tRPC API.

Now open the packages/client/src/App.tsx file and replace its content with the following:

packages/client/src/App.tsx


import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { getFetch, httpBatchLink } from "@trpc/client";
import { trpc } from "./utils/trpc";

function AppContent() {
  const hello = trpc.getHello.useQuery();
  return <main className="p-2">{JSON.stringify(hello.data, null, 2)}</main>;
}

export default function App() {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        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}>
        <AppContent />
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

We are now ready to start the tRPC client and server. To do that, change the directory into the root workspace and run yarn start . This will spin up the tRPC API on port 8000 and the tRPC client on port 3000.

Open http://localhost:3000/ in a new tab and you should see the JSON object sent by the getHello RPC.

testing the trpc client

Setup Tailwind CSS

Install tailwind CSS and its peer dependencies:


yarn add -D tailwindcss postcss autoprefixer

Generate both the tailwind.config.cjs and postcss.config.cjs files with the init command:


npx tailwindcss init -p

Open the tailwind.config.cjs file and replace its content with the following configurations:

packages/client/tailwind.config.cjs


/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        'ct-dark-600': '#222',
        'ct-dark-200': '#575757',
        'ct-dark-100': '#6d6d6d',
        'ct-blue-600': '#88abff',
        'ct-blue-700': '#6a93f8',
        'ct-yellow-600': '#f9d13e',
      },
      fontFamily: {
        Poppins: ['Poppins, sans-serif'],
      },
      container: {
        center: true,
        padding: '1rem',
        screens: {
          lg: '1125px',
          xl: '1125px',
          '2xl': '1125px',
          '3xl': '1500px'
        },
      },
    },
  },
  plugins: [],
};

Also, open the packages/client/src/index.css file and replace its content with the following CSS code:


@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');

@tailwind base;
@tailwind components;
@tailwind utilities;

html{
    font-family: 'Poppins', sans-serif;
}

body{
    background-color: #88abff;
}

Create Reusable Components

Now let’s create some reusable components with tailwind CSS. To do that, open your terminal in the client folder and install these dependencies.


yarn add tailwind-merge react-toastify react-hook-form zod @hookform/resolvers date-fns

Let’s start with a Spinner component that will be displayed whenever a request is being processed by the tRPC API.

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;

Here, let’s create a custom button with tailwind CSS that will consist of the Spinner component and text. The Spinner component will only be displayed when a request is in flight and the loading prop is true.

packages/client/src/components/LoadingButton.tsx


import React from "react";
import { twMerge } from "tailwind-merge";
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-blue-700",
  children,
  loading = false,
}) => {
  return (
    <button
      type="submit"
      className={twMerge(
        `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-white inline-block">Loading...</span>
        </div>
      ) : (
        <span className={`text-lg font-normal ${textColor}`}>{children}</span>
      )}
    </button>
  );
};

The last reusable component is a modal that will be rendered as a child component in the DOM node. To do that, we will use React Portal to render the modal outside of the current DOM hierarchy.

packages/client/src/components/note.modal.tsx


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

type INoteModal = {
  openNoteModal: boolean;
  setOpenNoteModal: (open: boolean) => void;
  children: React.ReactNode;
};

const NoteModal: FC<INoteModal> = ({
  openNoteModal,
  setOpenNoteModal,
  children,
}) => {
  if (!openNoteModal) return null;
  return ReactDom.createPortal(
    <>
      <div
        className="fixed inset-0 bg-[rgba(0,0,0,.5)] z-[1000]"
        onClick={() => setOpenNoteModal(false)}
      ></div>
      <div className="max-w-lg w-full rounded-md fixed top-0 xl:top-[10%] left-1/2 -translate-x-1/2 bg-white z-[1001] p-6">
        {children}
      </div>
    </>,
    document.getElementById("note-modal") as HTMLElement
  );
};

export default NoteModal;

After that, open the packages/client/index.html file and add a div with a id="note-modal" .

add the model id in the html

Create Note Component

This component will contain a form that comprises title and content fields. The form validation will be handled by React-Hook-Form and the validation schema will be defined with Zod.

packages/client/src/components/notes/create.note.tsx


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

type ICreateNoteProps = {
  setOpenNoteModal: (open: boolean) => void;
};

const createNoteSchema = object({
  title: string().min(1, "Title is required"),
  content: string().min(1, "Content is required"),
});

type CreateNoteInput = TypeOf<typeof createNoteSchema>;

const CreateNote: FC<ICreateNoteProps> = ({ setOpenNoteModal }) => {
  const queryClient = useQueryClient();
  const { isLoading, mutate: createNote } = trpc.createNote.useMutation({
    onSuccess() {
      queryClient.invalidateQueries([["getNotes"], { limit: 10, page: 1 }]);
      setOpenNoteModal(false);
      toast("Note created successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error) {
      setOpenNoteModal(false);
      toast(error.message, {
        type: "error",
        position: "top-right",
      });
    },
  });
  const methods = useForm<CreateNoteInput>({
    resolver: zodResolver(createNoteSchema),
  });

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

  const onSubmitHandler: SubmitHandler<CreateNoteInput> = async (data) => {
    createNote(data);
  };
  return (
    <section>
      <div className="flex justify-between items-center mb-3 pb-3 border-b border-gray-200">
        <h2 className="text-2xl text-ct-dark-600 font-semibold">Create Note</h2>
        <div
          onClick={() => setOpenNoteModal(false)}
          className="text-2xl text-gray-400 hover:bg-gray-200 hover:text-gray-900 rounded-lg p-1.5 ml-auto inline-flex items-center cursor-pointer"
        >
          <i className="bx bx-x"></i>
        </div>
      </div>
      <form className="w-full" onSubmit={handleSubmit(onSubmitHandler)}>
        <div className="mb-2">
          <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
            Title
          </label>
          <input
            className={twMerge(
              `appearance-none border border-gray-400 rounded w-full py-3 px-3 text-gray-700 mb-2  leading-tight focus:outline-none`,
              `${errors["title"] && "border-red-500"}`
            )}
            {...methods.register("title")}
          />
          <p
            className={twMerge(
              `text-red-500 text-xs italic mb-2 invisible`,
              `${errors["title"] && "visible"}`
            )}
          >
            {errors["title"]?.message as string}
          </p>
        </div>
        <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-gray-400 rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
              `${errors.content && "border-red-500"}`
            )}
            rows={6}
            {...register("content")}
          />
          <p
            className={twMerge(
              `text-red-500 text-xs italic mb-2`,
              `${errors.content ? "visible" : "invisible"}`
            )}
          >
            {errors.content && errors.content.message}
          </p>
        </div>
        <LoadingButton loading={isLoading}>Create Note</LoadingButton>
      </form>
    </section>
  );
};

export default CreateNote;

When the form is submitted, the createNote mutation will be evoked on the tRPC API to add the new record to the database. If the mutation resolves successfully, the queryClient.invalidateQueries([["getNotes"]]) method will be evoked to trigger the getNotes query on the tRPC API. Otherwise, an alert notification will be displayed to show the errors returned by the API.

Update Note Component

This component is similar to the above component but this time we will evoke the updateNote mutation on the tRPC API to update the record that matches the query in the database.

packages/client/src/components/notes/update.note.tsx


import React, { FC, useEffect } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoadingButton } from "../LoadingButton";
import { toast } from "react-toastify";
import { trpc } from "../../utils/trpc";
import { INote } from "../../type";
import { useQueryClient } from "@tanstack/react-query";

type IUpdateNoteProps = {
  note: INote;
  setOpenNoteModal: (open: boolean) => void;
};

const updateNoteSchema = object({
  title: string().min(1, "Title is required"),
  content: string().min(1, "Content is required"),
});

type UpdateNoteInput = TypeOf<typeof updateNoteSchema>;

const UpdateNote: FC<IUpdateNoteProps> = ({ note, setOpenNoteModal }) => {
  const queryClient = useQueryClient();
  const { isLoading, mutate: updateNote } = trpc.updateNote.useMutation({
    onSuccess() {
      queryClient.invalidateQueries([["getNotes"], { limit: 10, page: 1 }]);
      setOpenNoteModal(false);
      toast("Note updated successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error) {
      setOpenNoteModal(false);
      toast(error.message, {
        type: "error",
        position: "top-right",
      });
    },
  });
  const methods = useForm<UpdateNoteInput>({
    resolver: zodResolver(updateNoteSchema),
  });

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

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

  const onSubmitHandler: SubmitHandler<UpdateNoteInput> = async (data) => {
    updateNote({ params: { noteId: note.id }, body: data });
  };
  return (
    <section>
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-2xl text-ct-dark-600 font-semibold">Update Note</h2>
        <div
          onClick={() => setOpenNoteModal(false)}
          className="text-2xl text-gray-400 hover:bg-gray-200 hover:text-gray-900 rounded-lg p-1.5 ml-auto inline-flex items-center cursor-pointer"
        >
          <i className="bx bx-x"></i>
        </div>
      </div>{" "}
      <form className="w-full" onSubmit={handleSubmit(onSubmitHandler)}>
        <div className="mb-2">
          <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
            Title
          </label>
          <input
            className={twMerge(
              `appearance-none border border-gray-400 rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
              `${errors["title"] && "border-red-500"}`
            )}
            {...methods.register("title")}
          />
          <p
            className={twMerge(
              `text-red-500 text-xs italic mb-2 invisible`,
              `${errors["title"] && "visible"}`
            )}
          >
            {errors["title"]?.message as string}
          </p>
        </div>
        <div className="mb-2">
          <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
            Content
          </label>
          <textarea
            className={twMerge(
              `appearance-none border rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
              `${errors.content ? "border-red-500" : "border-gray-400"}`
            )}
            rows={6}
            {...register("content")}
          />
          <p
            className={twMerge(
              `text-red-500 text-xs italic mb-2`,
              `${errors.content ? "visible" : "invisible"}`
            )}
          >
            {errors.content && errors.content.message}
          </p>
        </div>
        <LoadingButton loading={isLoading}>Update Note</LoadingButton>
      </form>
    </section>
  );
};

export default UpdateNote;

Delete Note Component

Now that we are able to create and update records, let’s define a component to remove a record from the database. Before that, create a packages/client/src/type.ts file and add the following code. The INote type describes the note object that will be returned by the API.

packages/client/src/type.ts


export type INote = {
  id: string;
  title: string;
  content: string;
  category: string | null;
  published: boolean | null;
  createdAt: Date;
  updatedAt: Date;
};

Here, the onDeleteHandler function will be called to evoke the deleteNote RPC on the tRPC API to remove the record that matches the query from the database. After the note has been removed, the getNotes procedure will be evoked to return the first 10 records in the database.

packages/client/src/components/notes/note.component.tsx


import { FC, useState } from "react";
import { format, parseISO } from "date-fns";
import { twMerge } from "tailwind-merge";
import NoteModal from "../note.modal";
import UpdateNote from "./update.note";
import { toast } from "react-toastify";
import { INote } from "../../type";
import { trpc } from "../../utils/trpc";
import { useQueryClient } from "@tanstack/react-query";

type NoteItemProps = {
  note: INote;
};

const NoteItem: FC<NoteItemProps> = ({ note }) => {
  const [openSettings, setOpenSettings] = useState(false);
  const [openNoteModal, setOpenNoteModal] = useState(false);

  const queryClient = useQueryClient();
  const { mutate: deleteNote } = trpc.deleteNote.useMutation({
    onSuccess() {
      queryClient.invalidateQueries([["getNotes"], { limit: 10, page: 1 }]);
      setOpenNoteModal(false);
      toast("Note deleted successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error) {
      setOpenNoteModal(false);
      console.log(error);
      toast(error.message, {
        type: "error",
        position: "top-right",
      });
    },
  });

  const onDeleteHandler = (noteId: string) => {
    if (window.confirm("Are you sure")) {
      deleteNote({ noteId: noteId });
    }
  };
  return (
    <>
      <div className="p-4 bg-white rounded-lg border border-gray-200 shadow-md flex flex-col justify-between">
        <div className="details">
          <h4 className="mb-2 text-2xl font-semibold tracking-tight text-gray-900">
            {note.title.length > 20
              ? note.title.substring(0, 20) + "..."
              : note.title}
          </h4>
          <p className="mb-3 font-normal text-ct-dark-200">
            {note.content.length > 210
              ? note.content.substring(0, 210) + "..."
              : note.content}
          </p>
        </div>
        <div className="relative border-t border-slate-300 flex justify-between items-center">
          <span className="text-ct-dark-100 text-sm">
            {format(parseISO(String(note.createdAt)), "PPP")}
          </span>
          <div
            onClick={() => setOpenSettings(!openSettings)}
            className="text-ct-dark-100 text-lg cursor-pointer"
          >
            <i className="bx bx-dots-horizontal-rounded"></i>
          </div>
          <div
            id="settings-dropdown"
            className={twMerge(
              `absolute right-0 bottom-3 z-10 w-28 text-base list-none bg-white rounded divide-y divide-gray-100 shadow`,
              `${openSettings ? "block" : "hidden"}`
            )}
          >
            <ul className="py-1" aria-labelledby="dropdownButton">
              <li
                onClick={() => {
                  setOpenSettings(false);
                  setOpenNoteModal(true);
                }}
                className="py-2 px-4 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
              >
                <i className="bx bx-pencil"></i> Edit
              </li>
              <li
                onClick={() => {
                  setOpenSettings(false);
                  onDeleteHandler(note.id);
                }}
                className="py-2 px-4 text-sm text-red-600 hover:bg-gray-100 cursor-pointer"
              >
                <i className="bx bx-trash"></i> Delete
              </li>
            </ul>
          </div>
        </div>
      </div>
      <NoteModal
        openNoteModal={openNoteModal}
        setOpenNoteModal={setOpenNoteModal}
      >
        <UpdateNote note={note} setOpenNoteModal={setOpenNoteModal} />
      </NoteModal>
    </>
  );
};

export default NoteItem;

Get all Notes Component

Open the packages/client/src/App.tsx file and replace its content with the following code snippets. Here, we will trigger the getNotes query on the tRPC API to retrieve the first 10 records in the database.

packages/client/src/App.tsx


import "react-toastify/dist/ReactToastify.css";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { getFetch, httpBatchLink } from "@trpc/client";
import NoteModal from "./components/note.modal";
import CreateNote from "./components/notes/create.note";
import NoteItem from "./components/notes/note.component";
import { trpc } from "./utils/trpc";
import { ToastContainer, toast } from "react-toastify";

function AppContent() {
  const [openNoteModal, setOpenNoteModal] = useState(false);
  const { data: notes } = trpc.getNotes.useQuery(
    { limit: 10, page: 1 },
    {
      staleTime: 5 * 1000,
      select: (data) => data.notes,
      onError(err) {
        toast(err.message, {
          type: "error",
          position: "top-right",
        });
      },
    }
  );

  return (
    <div className="2xl:max-w-[90rem] max-w-[68rem] mx-auto">
      <div className="m-8 grid grid-cols-[repeat(auto-fill,_320px)] gap-7">
        <div className="p-4 h-72 bg-white rounded-lg border border-gray-200 shadow-md flex flex-col items-center justify-center">
          <div
            onClick={() => setOpenNoteModal(true)}
            className="flex items-center justify-center h-20 w-20 border-2 border-dashed border-ct-blue-600 rounded-full text-ct-blue-600 text-5xl cursor-pointer"
          >
            <i className="bx bx-plus"></i>
          </div>
          <h4
            onClick={() => setOpenNoteModal(true)}
            className="text-lg font-medium text-ct-blue-600 mt-5 cursor-pointer"
          >
            Add new note
          </h4>
        </div>
        {/* Note Items */}

        {notes?.map((note) => (
          <NoteItem key={note.id} note={note} />
        ))}

        {/* Create Note Modal */}
        <NoteModal
          openNoteModal={openNoteModal}
          setOpenNoteModal={setOpenNoteModal}
        >
          <CreateNote setOpenNoteModal={setOpenNoteModal} />
        </NoteModal>
      </div>
    </div>
  );
}

export default function App() {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        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}>
        <AppContent />
        <ToastContainer />
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Oops, quite a lot of code. Now change the directory into the root workspace and run yarn start to spin up both the server and client dev servers.

Testing the tRPC App

Once the Vite development server is listening on port 3000, open http://localhost:3000/ in a new tab.

Note: If you visit the app on http://127.0.0.1:3000/ , you’ll get site can’t be reached or CORS errors.

To add a record to the database, click on the plus icon to display a popup, provide the information and click on the Create Note button.

trpc app create new note mutation

To modify an existing record in the database, click on the three dots, then click on the edit menu to display a popup, change the information, and click on the Update Note button to edit the record in the database.

trpc app update existing note mutation

To remove a record from the database, click on the three dots again, select the delete menu, and accept the prompt to delete the record.

trpc app delete note mutation

When a user visits the root route, the first 10 records will be retrieved from the database and displayed in the UI.

trpc app get all records

Conclusion

Congrats on reaching the end. In this article, you learned how to build a full-stack tRPC CRUD App with React.js, Node.js, Express, Prisma, and SQLite. You can check out the official documentation for more details about the framework.

You can find the complete tRPC app project on GitHub