Synchronize Backend and Frontend Types With tRPC

Thibault Barrat
Thibault BarratApril 04, 2024
#react#typescript

In modern web applications, TypeScript has become a norm to enhance code reliability and minimize runtime errors. This is especially challenging for full-stack applications, where the frontend code needs precise typing when retrieving data from the backend. tRPC offers a solution to this problem by synchronizing the types between the backend and the frontend.

The Problem: Typing API Requests

The basic way to get a typed API request is to use a library like axios or fetch to make the requests and then manually type the response.

For example, if I fetch a list of movies from the backend, I would write something like this in the frontend:

type Movie = {
  id: number;
  title: string;
};

const response = await fetch('/api/movies');
const movies: Movie[] = await response.json();

However, this approach has some drawbacks: it is time-consuming, prone to mistakes, and difficult to maintain as you have to manually copy the types from the backend to the frontend.

Building Type-Safe API Endpoints

This is where tRPC comes in. tRPC is a TypeScript library designed to be used both in the backend and the frontend. It lets you define API endpoints on the server-side, and automatically get typed results when fetching these endpoints from the frontend. This ensures that the type system is always consistent across the full stack without any additional effort. The only requirement is to have your backend and frontend in a monorepo.

The API exposed by tRPC isn't REST: it's a Remote Procedure Call (RPC) API. This means that the frontend will call procedures on the backend, and get the results back. This is similar to how you would call a function in the frontend code, except the function is executed on the server.

Here is an example: an application listing movies I want to watch. I have set up the backend using Prisma, Express, and SQLite. Below is the database schema featuring the Movie and Director tables:

model Director {
  id     String  @id @default(uuid())
  name   String  @unique
  movies Movie[]
}

model Movie {
  id             String    @id @default(uuid())
  title          String    @unique
  director       Director? @relation(fields: [directorId], references: [id])
  directorId     String?
  year           Int
  synopsis       String
  imageLink      String
  link           String
  alreadyWatched Boolean
}

With my database schema in place, the next step involves establishing tRPC procedures. These procedures serve a similar function to REST endpoints or GraphQL's queries and mutations. tRPC names two distinct procedure types: queries for retrieving data and mutations for creating, updating, or deleting data, thereby outlining the CRUD operations of your API.

Procedures can accept inputs, such as retrieving a movie by its ID, with input types being validated by the zod library, a tool for schema validation.

Below are the procedures I created on the server side for my Director table:

export const directorRouter = router({
  getDirectors: procedure.query(() => {
    return prisma.director.findMany();
  }),
  getDirectorById: procedure
    .input(z.string().nullable())
    .query(({ input }) =>
      prisma.director.findUnique({ where: { id: input ?? undefined } })
    ),
  createDirector: procedure
    .input(z.object({ name: z.string() }))
    .mutation(({ input }) => {
      return prisma.director.create({ data: { name: input.name } });
    }),
  deleteDirector: procedure.input(z.string()).mutation(({ input }) => {
    return prisma.director.delete({ where: { id: input } });
  }),
});

Within tRPC, this concept is known as a router. A unique router can be established for every entity of the database model. For my application, I crafted a movieRouter and a directorRouter, both of which are combined in a global appRouter:

export const appRouter = router({
  movie: movieRouter,
  director: directorRouter,
});

Serving The API

Now, it's time to integrate tRPC into a web server. To ease this process, tRPC provides an Express adapter that can be used as an Express middleware. This middleware is then added to the Express server, and the tRPC router is mounted at a specific route. In the below code snippet, I mount the tRPC router at the /trpc route:

import { createExpressMiddleware } from "@trpc/server/adapters/express";
import cors from "cors";
import express from "express";

import { appRouter } from "./router";

const app = express();

app.use(cors());

app.use(
  "/trpc",
  createExpressMiddleware({
    router: appRouter,
  })
);

app.listen(4000);

tRPC also offers a variety of adapters for other backend frameworks, including Next.js, Fastify, among others.

The final step on the backend side involves exporting the appRouter type for utilization within the frontend:

export type AppRouter = typeof appRouter;

In this instance, I've employed only the fundamental functionalities of tRPC to develop a straightforward backend. Nonetheless, tRPC offers a variety of advanced features, including context and middleware, which can be used to establish protected procedures, for example.

Setting Up The Frontend

To consume my backend, I used React and React Query as tRPC offers a nice integration with React Query. The first step is to create the tRPC client and type it with the AppRouter type exported from the backend:

import { createTRPCReact, httpBatchLink } from "@trpc/react-query";

import type { AppRouter } from "../../server/src/index";

export const trpc = createTRPCReact<AppRouter>();

export const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: "http://localhost:4000/trpc",
    }),
  ],
});

The createTRPCReact function creates a tRPC client typed with AppRouter that can be used with React Query. The httpBatchLink is a link that sends multiple procedure calls in a single HTTP request to the backend, a feature I find particularly useful to reduce the number of requests sent to the backend.

To be able to use the tRPC client in the frontend, I wrapped my application with the trpc.Provider from tRPC and the QueryClientProvider from React Query:

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </trpc.Provider>
  </React.StrictMode>,
)

Consuming The API

With the tRPC client in place, I can now call the procedures from my React components. For example, to retrieve the list of directors:

const {
  data: directors,
  isLoading: isLoadingDirectors,
  refetch: refetchDirectors,
} = trpc.director.getDirectors.useQuery();

Or a mutation to create a director:

const createDirector = trpc.director.createDirector.useMutation({
  onSuccess: () => {
    refetchDirectors();
  },
});

const handleAddDirector = () => {
  const name = prompt("Director's name:");
  if (name) {
    createDirector.mutate({ name });
  }
};

As it is fully typed, I get IntelliSense for the procedures and their outputs and also type-checking for the inputs:

Batching Requests

I would like to highlight the batch request feature of tRPC. For example, during the render of my App component, I call both the getDirectors and getMovies query procedures:

  const {
    data: directors,
    isLoading: isLoadingDirectors,
    refetch: refetchDirectors,
  } = trpc.director.getDirectors.useQuery();

  const {
    data: movies,
    isLoading: isLoadingMovies,
    refetch: refetchMovies,
  } = trpc.movie.getMovies.useQuery();

If I look at the network tab of my browser developer tools, I see that only one HTTP request is sent to the backend with the URL /trpc/director.getDirectors,movie.getMovies?batch=1&input={} and the results of the two procedures are merged and returned in a single response:

Browser network tab for a batch request

This feature may or may not improve the performance of your application, depending on the number of procedures you call, whether you already use HTTP/2, and the size of the responses. However, it's a nice feature to have, especially if you have a lot of procedures to call.

Comparing tRPC With GraphQL

tRPC sounds a lot like GraphQL, which is also a Remote Procedure Call API. Routers are like resolvers, and both use the concept of queries and mutations.

However, tRPC is simpler than GraphQL. It doesn't try to be frontend and backend agnostic: it is designed to be used with TypeScript on both sides. This coupling is beneficial because it allows for a more seamless integration between the frontend and the backend without an intermediary schema.

According to the tRPC team, "GraphQL isn't that easy to get right". So if your API is moderately complex, tRPC may be a good alternative to GraphQL. On the other hand, if you're already using GraphQL, there's no need to switch to tRPC.

Conclusion

For a closer look at the detailed code of my application, feel free to explore it on this GitHub repository: marmelab/trpc-react-sqlite-demo.

I had a good developer experience using tRPC for the first time. Having my API types directly available in the frontend significantly boosted my peace of mind. It also permits me to have a better IntelliSense in my IDE when calling procedures from the frontend, which is always appreciated. I also appreciate the batch request feature that reduces the number of requests sent to the backend.

To go further with fully typed applications, I'm considering pairing tRPC with kysely, a fully typed SQL query builder for TypeScript. You can learn more about it in the article Build Type-Safe SQL Queries With Kysely, also published in this blog. Using both tRPC and kysely can lead to type safety from the database to the frontend.

Did you like this article? Share it!