Harnessing the Power of tRPC in Full-Stack TypeScript Development: Practical Journey

by Liben Hailu
9 mins read

Explore tRPC's, revolutionizing API development and enabling seamless client-server communication. Dive into Next.js integration, Prisma ORM utilization, and unlock the efficiency of type-safe workflows. Level up your TypeScript projects with tRPC's streamlined approach.


What is RPC

It is important to understand RPC before proceeding to tRPC. Remote Procedure Call (RPC) is a way for calling functions on one computer (server) from another computer (client).

What is tRPC

tRPC (typescript remote procedure call) is a TypeScript implementation of remote procedural call for monorepos. tRPC allows you to easily build and consume fully typesafe APIs without schemas and code generation. If your project is built with full-stack TypeScript, you can easily share types directly between your client and server without relying on code generation with tRPC.

Practice time

First things first, let's start by installing Next.js.

pnpm create next-app@latest my-app

tRPC is organized into separate packages, allowing you to install the specific components that are needed.

Let's start by installing those packages.

pnpm add @trpc/server @trpc/next @trpc/client @trpc/react-query @tanstack/react-query

As shown in the above command, we are utilizing numerous client libraries. However, they are not necessary for working with tRPC. Still, given their numerous benefits, I have included them in this project.

Now, it is time to create our server.

/src/server/trpc.ts
import { initTRPC } from "@trpc/server";
 
const t = initTRPC.create();
 
export const router = t.router;
export const publicProcedure = t.procedure;

It is a good practice to only export functions that are highly likely to be used and needed, rather than using the tRPC instance directly.

Now let's create a tRPC router.

A router is a collection of procedures or other routers. It serves as an organizational mechanism for managing and structuring API endpoints.

Procedures are functions that are exposed to the client and can be queries (GET operations), mutations (POST, PUT, DELETE operations), or subscriptions.

src/server/index.ts
import { publicProcedure, router } from "./trpc";
 
export const appRouter = router({
  getPets: publicProcedure.query(async () => {
    return [];
  }),
});
 
export type AppRouter = typeof appRouter;

As you can see in the above code, we have created a router and are exporting the type of appRouter, which will help type information to pass from the server to the client without directly importing appRouter itself.

Now it is time to use our tRPC router inside Next.js. To do this, first, we have to reroute all requests from Next.js API routes to the tRPC router.

src/app/api/trpc/[trpc]/route.ts
import { type NextRequest } from "next/server";
import { appRouter } from "@/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
 
const handler = (req: NextRequest) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: () => ({}),
  });
 
export { handler as GET, handler as POST };

Let's create our client provider now

src/trpc/provider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import React, { useState } from "react";
import { createTRPCReact } from "@trpc/react-query";
import { type AppRouter } from "@/server";
 
export const trpc = createTRPCReact<AppRouter>({});
 
export default function Provider({ children }: { children: React.ReactNode }) {
    const [queryClient] = useState(() => new QueryClient({}));
    const [trpcClient] = useState(() =>
        trpc.createClient({
            links: [
                httpBatchLink({
                    url: "http://localhost:3000/api/trpc",
                }),
            ],
        })
    );
    return (
        <trpc.Provider client={trpcClient} queryClient={queryClient}>
            <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
        </trpc.Provider>
    );
}

Now let's use our query inside of our react component.

src/components/PetList.tsx
const PetList = () => {
    const getPets = trpc.getPets.useQuery()
    return (<>
        {getPets.data?.length === 0 ? <Empty /> :
            <div className="space-y-2">
                <h1 className="text-2xl font-bold">My Pets</h1>
                {getPets.data?.map(pet => <PetItem key={pet.name} name={pet.name} owner={pet.owner} />)
                }
            </div>
        }
    </>)
}
 
export default PetList

Now, let's integrate our application with a database. For now, let's use SQLite as the database. First, let's install the necessary libraries to work with SQLite. We will use the Prisma ORM (Object-Relational Mapper) to interact with the SQLite database.

npm install -D prisma && npm install @prisma/client

Now, let's initialize our Prisma schema using SQLite as the data provider.

npx prisma init --datasource-provider sqlite

let's update our prisma schema

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}
 
model Pet {
  id    String @id @default(uuid())
  name  String
  owner String
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  @@map(name: "pets")
}
 

Now let's run the following commands.

npx prisma generate
npx prisma db push

Now let's fetch pets from our database.

/src/server/index.ts
export const appRouter = router({
  getPets: publicProcedure.query(async () => {
    try {
      const pets = await prisma.pet.findMany();
      return pets;
    } catch (error) {
      throw error;
    }
  }),
});

At this point, our pets array should be empty. Let's start by creating a procedure to add a pet. Before we proceed, let's install zod, a validation library, to validate input on the server.

Validating the input and output of procedures is straightforward in tRPC. You can set up an input validator using various validation libraries. While validating output is not common, it is sometimes necessary. For example, when returning data from an untrusted source or when avoiding the transmission of sensitive data to the client. If output validation fails, it will result in an INTERNAL_SERVER_ERROR.

pnpm add zod

Now let's write our procedure.

/src/server/index.ts
createPet: publicProcedure.input(z.object({ name: z.string(), owner: z.string() })).mutation(async ({ input }) => {
    try {
        const pet = await prisma.pet.create({
            data: {
                name: input.name,
                owner: input.owner
            }
        })
        return pet
    }
    catch (error) {
        throw error;
    }
}),

Let's use our mutation form CreatePet component.

/src/components/CreatePet.tsx
const createPet = trpc.createPet.useMutation({
  onSuccess: () => {
    router.refresh();
    setName("");
    setOwner("");
  },
});
 <form onSubmit={(e) => {
    e.preventDefault();
    createPet.mutate({ name, owner });}
    >
    ...
</form>

Now let's see how to call function on a server. first lets create a server client.

/src/trpc/server-client.ts
import { appRouter } from "@/server";
import { httpBatchLink } from "@trpc/client";
 
export const serverClient = appRouter.createCaller({
  links: [
    httpBatchLink({
      url: "http://localhost:3000/api/trpc",
    }),
  ],
});

Let's use our server client inside our home page server component.

src/app/page.tsx
export default async function Home() {
  const pets = await serverClient.getPets()
  return (
    <main className="mx-auto max-w-xl my-14">
      <CreatePet />
      <PetList initialData={pets} />
    </main>
  );
}

Time to update out PetList component, let's use the pets list from our server component as initialData.

src/components/PetList.tsx
const PetList = ({ initialData }: { initialData: Pet[] }) => {
    const getPets = trpc.getPets.useQuery(undefined, { initialData: initialData, refetchOnMount: false, refetchOnReconnect: false })
    return (<>
        {getPets.data?.length === 0 ? <Empty /> :
            <div className="space-y-2">
                <h1 className="text-2xl font-bold">My Pets</h1>
                {getPets.data?.map(pet => <PetItem key={pet.name} id={pet.id} name={pet.name} owner={pet.owner} />)
                }
            </div>
        }
    </>)
}

source code available on github here.