Editor’s note: This article was last updated by Ikeh Akinyemi on 26 May 2023 to give an overview of the four API architectural styles: RPC, REST, GraphQL, and tRPC.
You may already be familiar with the remote procedure call framework gRPC. Given the similarity in naming, you might be inclined to believe that tRPC is somehow related to it, or does the same or a similar thing. However, this is not the case.
While tRPC is indeed also a remote procedure call framework, its goals and basis differ fundamentally from gRPC. The main goal of tRPC is to provide a simple, type-safe way to build APIs for TypeScript- and JavaScript-based projects with a minimal footprint.
In this article, we’ll build a simple, full-stack TypeScript app using tRPC that will be type-safe when it comes to the code and across the API boundary. We’ll build a small, cat-themed application to showcase how to set up tRPC on the backend and how to consume the created API within a React frontend. You can find the full code for this example on GitHub. Let’s get started!
Jump ahead:
useMutation
from React Query with TypeScriptWhile building full-stack applications, one of the key aspects developers need to consider is the manner in which the frontend and backend communicate. This interplay is facilitated through APIs, and there are several architectural styles to choose from. Here, we will give an overview of four: RPC, REST, GraphQL, and tRPC.
RPC is a protocol that one program can use to request a service from a program located on another computer in a network without having to understand the network’s details. It’s like calling a function that’s on another machine. There are many implementations of RPC, including gRPC, XML-RPC, and tRPC, but the overarching concept remains the same: you’re invoking a procedure on a remote machine.
REST is an architectural style that drives the web and has been a standard for designing web APIs. In a RESTful system, resources are identified by URLs and are accessed and manipulated using HTTP methods such as GET, POST, PUT, DELETE, etc. REST advocates for stateless communication, meaning each HTTP request happens independently and does not need to know about any requests that have happened previously.
GraphQL is a query language for APIs and a runtime for executing those queries with existing data. It allows the client to define the structure of the data required, and the server then returns precisely the data the client asked for. This can make it more efficient than RESTful APIs, where the server defines what data is returned.
tRPC is an innovative remote procedure call (RPC) framework designed specifically to leverage TypeScript’s powerful inference to derive the type definitions of an API router. This allows developers to invoke API procedures directly from the frontend with full type safety and autocompletion, leading to a more integrated and efficient development experience.
If you have an application that uses TypeScript on both the backend and the frontend, tRPC helps you set up your API in a way that incurs the absolute minimum overhead in terms of dependencies and runtime complexity. However, tRPC still provides type safety and all the features that come with it, like auto-completion for the whole API and errors for when the API is used in an invalid way.
In practical terms, you can think of tRPC as a very lightweight alternative to GraphQL. However, tRPC is not without its limitations. For one, it’s limited to TypeScript and JavaScript. Additionally, the API you are building will follow the tRPC model, which means it won’t be a REST API. You can’t simply convert a REST API to tRPC and have the same API as before but with types included.
Essentially, tRPC is a batteries-included solution for all your API needs, but it will also be a tRPC-API. That’s where the RPC in the name comes from, fundamentally changing how remote calls work. tRPC could be a great solution for you as long as you’re comfortable using TypeScript on your API gateway.
Let’s start by creating a folder in our project root called backend
. Within the backend
folder, we’ll create a package.json
file as follows:
{ "name": "backend", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start-server": "./node_modules/.bin/tsc && node build/index.js" }, "dependencies": { "@trpc/server": "^10.29.1", "cors": "^2.8.5", "express": "^4.18.2", "zod": "^3.21.4" }, "devDependencies": { "@tsconfig/node14": "^1.0.1", "@types/cors": "^2.8.13", "@types/express": "^4.17.17", "typescript": "^4.5" }, "keywords": [], "author": "", "license": "ISC" }
We’ll also create a tsconfig.json
file:
{ "extends": "@tsconfig/node14/tsconfig.json", "compilerOptions": { "outDir": "build" } }
Next, we’ll create a folder called src
and include an index.ts
file within it. Finally, we’ll execute npm install
in the backend
folder. With that, we’re done with the setup for the backend.
For the frontend, we’ll use Create React App to set up a React app with TypeScript support using the following command within the project root:
$ npx create-react-app frontend --template typescript
We can also run npm install
in the frontend
folder and run the app with npm start
to see that everything works and is set up properly. Next, we’ll implement the backend of our application.
As you can see above in the package.json
of the server
part of our application, we use Express as our HTTP server. Additionally, we add TypeScript and the trpc-server
dependency.
Beyond that, we used the cors
library for adding CORS to our API, which isn’t really necessary for this example, but it is good practice. We also add Zod, a schema validation library with TypeScript support, which is often used in combination with tRPC. However, you can also use other libraries like Yup or Superstruct. We’ll see exactly what this is for later on.
With the dependencies out of the way, let’s set up our basic Express backend with tRPC support.
We’ll start by defining the tRPC router, which is a very important part of this whole infrastructure, allowing us to wire together our backend and frontend in terms of type safety and autocompletion. This router should be in its own file, for example router.ts
, as we’ll import it into our React app later on as well.
Within backend/src/router.ts
, we start by defining the data structure for our domain object, Cat
:
import z from 'zod'; let cats: Cat[] = []; const Cat = z.object({ id: z.number(), name: z.string(), }); const Cats = z.array(Cat); ... export type Cat = z.infer<typeof Cat>; export type Cats = z.infer<typeof Cats>;
You might be wondering why we’re not building simple JavaScript or TypeScript types and objects. Because we use Zod for schema validation with tRPC, we also need to build these domain objects with it. We can actually add validation rules using Zod, like a maximum amount of characters for a string, email validation, and more, combining type checking with actual validation.
We also get automatically created error messages when an input is not valid. However, these errors can be entirely customized. If you’re interested in validation and error handling, check out the docs for more information.
After implementing our type using Zod, we can infer a TypeScript type from it using z.infer
. Once we have that, we export the type to use in other parts of the app, like the frontend, and then move on to creating the heart of the application.
Let’s update the backend/src/r
outer.ts
file with the following router definition:
... const trpcRouter = t.router({ get: t.procedure.input(z.number()).output(Cat).query((opts) => { const { input } = opts; const foundCat = cats.find((cat => cat.id === input)); if (!foundCat) { throw new TRPCError({ code: 'BAD_REQUEST', message: `could not find cat with id ${input}`, }); } return foundCat; }), list: t.procedure.output(Cats).query(() => { return cats; }), }) ...
In the above snippet, we constructed a tRPC router by calling the router()
method and defining our different endpoints within it. With tRPC, there are two primary types of procedures:
GET
method in RESTPOST
, PUT
, PATCH
, and DELETE
methods in RESTHere, we create our query
endpoints, one for retrieving a singular Cat
object by its ID and another for fetching all Cat
objects. tRPC also supports the concept of infiniteQuery
, which uses a cursor to return a paged response of potentially infinite data, if needed.
For the get
endpoint, we define an input
schema and output
schema. This endpoint is essentially a GET
operation that returns the JSON of our Cat
object based on the given ID. The input
is the ID of the Cat
object we want to fetch.
Inside the query()
function, we implement our actual business logic. For instance, in a real-world application, we might call a service or a database layer. However, because we’re simply storing our Cat
objects in-memory (in this case, an array), we search for Cat
with the given ID. If we don’t find Cat
with the given ID, we throw an error. If we find Cat
, we return it.
The list
endpoint is simpler as it takes no input and only returns our current list of Cat
objects. It defines an output
schema and a query()
function that returns all Cat
objects.
The use of the .procedure
method along with chained .input
, .output
, and .query
or .mutation
methods provides a clear, type-safe way of defining the request and response structures and the behavior of each endpoint.
Let’s look at how we can implement creation and deletion with tRPC:
... const trpcRouter = t.router({ ... create: t.procedure .input( z.object({ name: z.string().max(50) }), ) .mutation((opts) => { const { input } = opts; const newCat: Cat = { id: newId(), name: input.name }; cats.push(newCat) return newCat }), delete: t.procedure.output(z.string()).input(z.object({ id: z.number() })).mutation((opts) => { const { input } = opts; cats = cats.filter(cat => cat.id !== input.id); return "success" }) }) function newId(): number { return Math.floor(Math.random() * 10000) } ... export type TRPCRouter = typeof trpcRouter; export default trpcRouter;
As you can see, we use the .procedure.input().mutation()
method to create a new mutation.
The create
mutation expects an input object with a name
attribute, which is a string limited to a maximum of 50 characters. This is handled by the z.object()
call with the name:
z.string().max(50)
attribute. Within the .mutation()
method, a new Cat
object is created using the provided name
and a randomly generated id
(generated by the newId
function). This new Cat
object is then added to the cats
array and returned.
The create
mutation will result in something like a POST /create
, expecting some kind of body.
Similarly, the delete
mutation expects an input object with an id
attribute, which is a number. In the .mutation()
method, the cats
array is filtered to remove the Cat
object with the provided id
, effectively deleting the Cat
from the array. This operation returns a string “success” upon completion.
The responses don’t actually look like what we define here. Rather, they are wrapped inside of a tRPC response like the one below:
{"id":null,"result":{"type":"data","data":"success"}}
And that’s it for our router; we have all the endpoints we need. Now, we’ll have to wire it up with an Express web app. Create a ./backend/src/index.ts
file and add the following:
import express, { Application } from 'express'; import cors from 'cors'; import * as trpcExpress from '@trpc/server/adapters/express'; import trpcRouter, { createContext } from './router'; const app: Application = express(); app.use(express.json()); app.use(cors()); app.use( '/cat', trpcExpress.createExpressMiddleware({ router: trpcRouter, createContext, }), ); app.listen(8080, () => { console.log("Server running on port 8080"); });
tRPC comes with an adapter for Express, so we simply create our Express application and use the provided tRPC middleware inside of the app. We can define a sub-route where this configuration should be used, a router, and a context.
The context function is called for each incoming request, and it passes its result to the handlers. In the context function, you could add the context data you want for each request, like an authentication token or the userId
of a user who is logged in.
If you want to learn more about authorization with tRPC, there’s a section about it in the docs.
That’s it for the app! Let’s test it quickly so we know everything is working correctly. We can start the app by changing directory into the backend folder and executing the npm run
command:
$ npm run start-server
Now the server is running, let’s send off some HTTP requests using cURL. First, let’s create a new Cat
:
$ curl -X POST "http://localhost:8080/cat/create" -d '{"name": "Minka" }' -H 'content-type: application/json' {"id":null,"result":{"type":"data","data":{"id":7216,"name":"Minka"}}}
Then, we can list the existing Cat
objects:
$ curl "http://localhost:8080/cat/list" {"id":null,"result":{"type":"data","data":[{"id":7216,"name":"Minka"}]}}
We can also fetch the Cat
by its ID:
$ curl "http://localhost:8080/cat/get?input=7216" {"id":null,"result":{"type":"data","data":{"id":7216,"name":"Minka"}}}
And finally, delete a Cat
:
$ curl -X POST "http://localhost:8080/cat/delete" -d '{"id": 7216}' -H 'content-type: application/json' {"id":null,"result":{"type":"data","data":"success"}} $ curl "http://localhost:8080/cat/list" {"id":null,"result":{"type":"data","data":[]}}
Everything seems to work as expected. Now, with the backend in place, let’s build our React frontend.
First, within the client/src
folder, let’s create a cats
folder to add some structure in our application. Then, we update the package.json
to reflect the additional dependencies:
{ "name": "frontend", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/react-query": "^4.29.12", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@trpc/client": "^10.29.1", "@trpc/react-query": "^10.29.1", "@types/jest": "^27.5.2", "@types/node": "^16.18.34", "@types/react-dom": "^18.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ] }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } }
In this example, like in the official ones, we’ll use React Query, which adds API-interaction to React apps. Adding React Query is completely optional, and it’s possible to just use a vanilla client with the frontend framework of your choice, including React, and integrate it exactly the way you want to.
Let’s start by building the basic structure of our app in App.tsx
:
import { useState } from 'react'; import './App.css'; import { httpBatchLink } from '@trpc/client'; import type { TRPCRouter } from '../../backend/src/router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import Create from './cats/Create'; import Detail from './cats/Detail'; import List from './cats/List'; import { createTRPCReact } from '@trpc/react-query'; const BACKEND_URL = "http://localhost:8080/cat"; export const trpc = createTRPCReact<TRPCRouter>(); function App() { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: BACKEND_URL, }), ], }), ); const [detailId, setDetailId] = useState(-1); const setDetail = (id: number) => { setDetailId(id); } return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> <div className="App"> <Create /> <List setDetail={setDetail}/> { detailId > 0 ? <Detail id={detailId} /> : null } </div> </QueryClientProvider> </trpc.Provider> ); } export default App;
There’s quite a bit to unpack, so let’s start from the top. We instantiate trpc
using the createTRPCReact
helper from @trpc/react-query
, giving it the TRPCRouter
that we import from our backend app. We also export for use in the rest of our app.
Essentially, this creates all the bindings towards our API underneath. Next, we create a React Query client and a tRPC-client, providing it the URL for our backend. We’ll use this client to make requests to the API, or rather that the client React Query will use underneath.
In addition to all of this setup, we also define a state variable for detailId
so we know which Cat
detail to show if the user selects any.
If you check out what we return from App
, you can see that our actual markup, the div
with the App
class, is nested within two layers. These layers are on the outer side, the tRPC provider, and inside that, the React Query provider.
These two components make the necessary moving parts available to our whole application. Therefore, we can use tRPC throughout our application, and our query calls get seamlessly integrated with our React app. Next, we’ll add components for Create
, List
, and Detail
to our markup, which will include all of our business logic.
Let’s start with the Create
component by creating a Create.css
and Create.tsx
file inside the src/cats
folder. In this component, we’ll simply create a form and connect the form to the create
mutation we implemented on the backend. Once a new Cat
object has been created, we want to re-fetch the list of Cat
objects so that it’s always up to date. We could implement this with the following code:
import './Create.css'; import { ChangeEvent, useState } from 'react'; import { trpc } from '../App'; function Create() { const [text, setText] = useState(""); const [error, setError] = useState(""); const cats = trpc.list.useQuery(); const createMutation = trpc.create.useMutation({ onSuccess: () => { cats.refetch(); }, onError: (data) => { setError(data.message); } }); const updateText = (event: ChangeEvent<HTMLInputElement>) => { setText(event.target.value); }; const handleCreate = async() => { createMutation.mutate({ name: text }); setText(""); }; return ( <div className="Create"> {error && error} <h2>Create Cat</h2> <div>Name: <input type="text" onChange={updateText} value={text} /></div> <div><button onClick={handleCreate}>Create</button></div> </div> ); } export default Create;
Let’s start off with some very basic, vanilla React logic. We create some internal component state for our form field and the potential errors we might want to show. We return a simple form featuring a text field connected to our state, as well as a button to submit it.
Now, let’s look at the handleCreate
function. We call .mutate
on the createMutation
, which we define above it and reset the text
field afterwards.
The createMutation
is created using trpc.create.useMutation
endpoint. In your IDE or editor, note that when typing create
call, you’ll get autocomplete suggestions. We also get suggestions in the payload for the .mutate
call suggesting that we use the name
field.
Inside the .useMutation
call, we define what should happen on success and on error. If we encounter an error, we simply want to display it using our component-internal state. If we successfully create a Cat
object, we want to re-fetch the data for our list of Cat
objects. For this purpose, we define a call to the endpoint, trpc.list.useQuery
, and called it inside the onSuccess
handler.
We can already see how easy it is to integrate our app with the tRPC API, as well as how tRPC helps us during development. Let’s look at the detail view next, creating Detail.tsx
and Detail.css
within the cats
folder:
import './Detail.css'; import { trpc } from '../App'; function Detail(props: { id: number, }) { const cat = trpc.get.useQuery(props.id); return ( cat.data ? <div className="Detail"> <h2>Detail</h2> <div>{cat.data.id}</div> <div>{cat.data.name}</div> </div> : <div className="Detail"></div> ); } export default Detail;
In the component above, we essentially used .get.useQuery
to define our getCatById
endpoint, providing the ID we get from our root component via props. If we actually get data, we render the details of Cat
. We could also use useEffects
for the data fetching here. Essentially, any way you would integrate an API with your React app will work fine with tRPC and React Query.
Finally, let’s implement our List
component by creating List.css
and List.tsx
in cats
. In our list of Cat
objects, we’ll display the ID and name of a Cat
, as well as a link to display it in detail and a link to delete it:
import './List.css'; import { trpc } from '../App'; import type { Cat } from '../../../backend/src/router'; import { useState } from 'react'; function List(props: { setDetail: (id: number) => void, }) { const [error, setError] = useState(""); const cats = trpc.list.useQuery(); const deleteMutation = trpc.delete.useMutation({ onSuccess: () => { cats.refetch(); }, onError: (data) => { setError(data.message); } }); const handleDelete = async(id: number) => { deleteMutation.mutate({ id }) }; const catRow = (cat: Cat) => { return ( <div key={cat.id}> <span> {cat.id} </span> <span> {cat.name} </span> <span> <a href="#" onClick={props.setDetail.bind(null, cat.id)}>detail</a> </span> <span> <a href="#" onClick={handleDelete.bind(null, cat.id)}>delete</a> </span> </div> ); }; return ( <div className="List"> <h2>Cats</h2> <span>{error}</span> { cats.data && cats.data.map((cat) => { return catRow(cat); })} </div> ); } export default List;
This component basically combines the functionality we used in the two previous ones. For one, we fetch the list of cats using the list.useQuery
endpoint and also implement the deletion of Cat
objects with a subsequent re-fetch using deleteMutation
, pointing to our delete
mutation on the backend.
Besides that, everything is quite similar. We pass in the setDetailId
function from App
via props so that we can set the cat to show details in Detail
and create a handler for deleting a cat, which executes our mutation.
Notice all the autocompletion provided by tRPC. If you mistype something, for example, the name of an endpoint, you will get an error, and the frontend won’t start until the error is corrected. That’s it for our frontend, let’s test it and see tRPC in action!
First, let’s start the app with npm start
and see how it works. Once the app is up, we can create new cats, delete them, and watch their detail page while observing the changes directly in the list. It’s not particularly pretty, but it works:
Let’s take a look at how tRPC can help us during our development process. Let’s say we want to add an age
field for our cats:
const Cat = z.object({ id: z.number(), name: z.string(), age: z.number(), }); ... const trpcRouter = t.router({ ... create: t.procedure .input( z.object({ name: z.string().max(50), age: z.number().min(1).max(30) }), ) .mutation((opts) => { const { input } = opts; const newCat: Cat = { id: newId(), name: input.name, age: input.age }; cats.push(newCat) return newCat }), ... }) ...
We add the field to our domain object, and we also need to add it to our create
endpoint. Once you hit save on your backend code, navigate back to your frontend code in ./frontend/src/cats/Create.tsx
. Our editor shows us an error because the property age
is missing in our call to createMutation
:
If we want to add the age
field to our mutation now, our editor will provide us with autocomplete with full type-information directly from our changed router.ts
:
From my perspective, this is the true power of tRPC. While it’s nice to have a simple way to create an API both on the frontend and backend, the real selling point is that the code won’t build if I make a breaking change on one side and not the other.
For example, imagine a huge codebase with multiple teams working on API endpoints and UI elements. Having this kind of safety in terms of API compatibility with almost no overhead to the application is quite remarkable.
In order to compare tRPC with GraphQL, let’s look at several key factors including type safety, performance, and simplicity.
Both tRPC and GraphQL offer type safety, which is crucial for maintaining robust codebases, reducing bugs, and improving developer productivity. However, they approach type safety differently:
Performance comparisons between tRPC and GraphQL can be nuanced, as they depend on the specific use case, how the API is designed and used, and what specific optimizations are applied:
Simplicity can be subjective and dependent on developer’s familiarity with the technologies and concepts involved:
tRPC and REST offer different approaches to building APIs. tRPC defines the API implementation as the contract, simplifying the API but requiring frontend and backend rebuilds for certain changes. On the other hand, REST uses a separate contract to represent the API, necessitating rebuilds only when the contract changes.
While tRPC structures APIs as RPC calls and offers a simpler client-side API, it may lose the predictability of REST APIs for non-TypeScript or public consumers. REST maintains the system’s safety and predictability with conventional API design and RPC-type client calls without requiring additional abstraction layers.
tRPC and gRPC both provide type-safe client/server APIs but operate differently in most aspects. gRPC leverages protobuf for a compact wire protocol with schemas defined in a proto
file, and these proto
files generate clients/servers in various languages (C++, Java, Go, Python).
On the other hand, tRPC takes a distinct approach, defining its schemas directly in TypeScript, which can then be dynamically imported by both client and server. It is not as optimized as gRPC over the wire because it uses HTTP, but it’s much simpler to use. Unlike gRPC, tRPC being a TypeScript-only library is web-native — designed to operate effectively in a browser environment.
useMutation
from React Query with TypeScriptIn a scenario where you’re using tRPC with React Query and TypeScript, you might find yourself needing to use multiple parameters in useMutation
. This can be achieved by using a JavaScript object to wrap your parameters.
For example, let’s assume you have a login procedure in your tRPC router that accepts a single parameter, name
, and returns a user object. In your component, you can make use of useMutation
to call this login procedure, passing in an object with the name
property.
Here’s a quick example:
import { trpc } from '../utils/trpc'; export function MyComponent() { // This can either be a tuple ['login'] or string 'login' const mutation = trpc.login.useMutation(); const handleLogin = () => { const name = 'logrocket blog'; const email = '[email protected]'; mutation.mutate({ name, email }); }; return ( <div> <h1>Login Form</h1> <button onClick={handleLogin} disabled={mutation.isLoading}> Login </button> {mutation.error && <p>Something went wrong! {mutation.error.message}</p>} </div> ); }
This above snippet sets up a mutation that calls the login procedure in tRPC. When the handleLogin
function is called, it triggers the mutation with the name
and email
parameters wrapped in an object.
To pass multiple parameters, you would simply add more properties to the object passed to mutation.mutate
. For example, if your login procedure also required a password, you could call mutation.mutate({ name, age, password })
.
Hopefully, this article showed you how tRPC can be useful in situations when you’re using TypeScript both on the frontend and backend. I love the low-footprint approach. With minimal or no extra dependencies, you can focus on compile-time correctness instead of runtime-checking.
Obviously, in certain cases, the TypeScript limitation might be too much to bear. The principle behind tRPC is great in terms of developer experience. tRPC is an exciting project that I will certainly keep my eye on in the future. Here’s the GitHub repo for this project.
Happy coding!
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]