A First Look at tRPC
Published:
tRPC is a TypeScript library for building end-to-end, type-safe APIs. It creates fully typed endpoints on the backend which are queried from a frontend client.
Outline
All of this project’s code can be found in the First Look monorepo on my GitHub.
Introduction
tRPC is a TypeScript library for building end-to-end, type-safe APIs. It creates fully typed endpoints on the backend which can be queried from a frontend written in TypeScript. While it is typically integrated with React or Next, it can be used with Vue, Svelte, or plain TypeScript.
The project began in July 2020 as a proof of concept from Colin McDonnell, the creator of Zod. Originally called ZodRPC, Colin described it as “a toolkit for creating type-safe backends powered by Zod.” What is Zod, you say? Glad you asked!
Schema Validation with Zod
Zod is a TypeScript-first schema declaration and validation library that uses a “schema” to broadly refer to any data type which can be declared with static types in TypeScript. Here is how Colin described Zod when the project was first released:
Zod is a validation library designed for optimal developer experience. It’s a TypeScript-first schema declaration library with rigorous (and correct!) inferred types, incredible developer experience, and a few killer features missing from the existing libraries.
- Uses TypeScript generic inference to statically infer the types of your schemas.
- Eliminates the need to keep static types and runtime validators in sync by hand.
- Has a composable, declarative API that makes it easy to define complex types concisely.
Colin McDonnell - Designing the perfect TypeScript schema validation library (March 8, 2020)
With ZodRPC, Colin hoped to build a new library that would extend the functionality of Zod to the point of offering an alternative to GraphQL. The key to the project’s success would be retaining the simplicity of RPC calls which consist of only named functions that accept arguments and return values.
Colin knew ZodRPC (later renamed to tRPC) had the potential to provide a similar experience to GraphQL’s end to end type-safety but without all the associated tooling and boilerplate code accompanying most GraphQL projects. Here is how he described it in Building an end-to-end type-safe API without GraphQL (June 13, 2021):
Most people use GraphQL as a massively over-engineered way to share the type signature of their API with your client. What if you could import that type signature directly into your client using plain ole
import
syntax? Well… you can.
Colin didn’t continue to maintain the project and essentially abandoned it. Alex “KATT” Johansson then found the repo after finding a tweet by Colin with a small proof of concept containing two to three files. Alex decided to fork it rather than do his own thing since he hoped he wouldn’t be alone.
Having already contributed to Blitz, the idea already resonated with the approach he was looking for, especially the data layer. Alex has been maintaining the project ever since. It has gained a large cult following in the time since Alex took over the project and his leadership has been so inextricably integral to the project that many assume that he created the project.
Similarities & Differences with GraphQL
Throughout 2022, tRPC was increasingly pitted against GraphQL as an existential threat ironically mirroring the evangelism of GraphQL over REST over half a decade ago. I’ve recently been seeing lots of mea culpas and “I told you so’s” on GraphQL due to the current rise of tRPC.
But I’ve seen reasonable takes from developers (only some of which work for GraphQL companies) who believe in the staying power of GraphQL and argue it still has unique strengths that make it a superior choice over tRPC in certain cases. This topic would require an entirely separate blog post to thoroughly analyze the disparate considerations.
In lieu of that post, I’ll quickly highlight a few of the resources already available on this topic and direct the readers to those links to learn more. First and foremost, here’s how my old StepZen co-worker, Roy Derks, compares the two in Can you compare GraphQL and tRPC? (November 27, 2022):
GraphQL is a query language for your API, while tRPC is a set of libraries for building end-to-end type-safe APIs. GraphQL uses HTTP as a transport layer with GraphQL queries sent as POST requests. It excels in combining multiple data sources into a single query and is often used for data modeling and architecture.
tRPC is not really meant for combining multiple data sources. It’s a set of libraries that use TypeScript to ensure type safety throughout the application. Its primary purpose is making it easy to build end-to-end type-safe applications in a single codebase by using the same types in your frontend and backend.
In The simplicity of tRPC with the power of GraphQL (December 11, 2022), Jens Neuse from Wundergraph says you should consider the following context when deciding whether the strengths of GraphQL outweigh its weaknesses:
The great developer experience of tRPC is achieved by merging two very different concerns into one concept, API implementation and data consumption. It’s important to understand that this is a trade-off. There’s no free lunch. The simplicity of tRPC comes at the cost of flexibility.
With GraphQL, you have to invest a lot more into schema design, but this investment pays off the moment you have to scale your application to many but related pages. By separating API implementation from data fetching it becomes much easier to re-use the same API implementation for different use cases.
Finally, for the most thorough discussion comparing tRPC and GraphQL I’d recommend watching Theo’s GraphQL, tRPC, and REST video followed by his debate with Max Stoiber, Discussing tRPC & GraphQL. In the months before this debate, Theo tweeted the following chart along with the message:
tRPC is not the death of GraphQL, it just means I reach for it way less :)
Project Setup
To begin, create a blank directory with a .gitignore
file and initialize a package.json
with pnpm
.
Configure Node & TypeScript
We’ll add three scripts to our project’s package.json
. These perform the following commands:
dev:server
- runs the development server withtsx
build:server
- creates a bundle indist
start:server
- runs the bundle generated indist
- For the server side dependencies we’ll install tRPC’s server implementation
@trpc/server
andzod
. - For development dependencies on the server we’ll install
npm-run-all
,tsx
,typescript
, and@types/node
for the Node.jsDefinitelyTyped
type definitions.
Create a tsconfig.json
file for your TypeScript configuration on the server.
Include the following compiler options which I copy pasted from a tRPC example app and do not understand at all.
Create Project Files
For a Hello World example, we can create an entire server in a single index.ts
file. After demonstrating how to do this, we’ll break apart the server into multiple files in a structure that reflects the organization of larger, real world tRPC applications.
Since we’ll create a client application later in the tutorial, the index.ts
server file is placed in a directory called server
to differentiate between the two sides.
Create HTTP Server
initTRPC
initializes a tRPC variable t
which can setup a request context, metadata, or runtime configuration. createHTTPServer
is imported from the standalone
adapter in @trpc/server
and used to initialize an HTTP server with Node.js.
There are adapters for other Node environments including:
There are also a set of adapters that utilize the Fetch API including:
Run Node HTTP Server
Start the Node server located in index.ts
with tsx
in watch
mode by running pnpm dev:server
. The terminal should display a message saying, listening on localhost:2022
:
With cURL, perform a GET
request on http://localhost:2022/hello
to receive a JSON response from the hello
route. I included the expected output as well with a comment under the curl
command:
As our project grows and more routes are added, it will be more maintainable to break apart pieces of the code into multiple, modular files. In the next section, we’ll do just this.
Create Context & Hello Router
A common way to structure a tRPC project includes a clear delineation between individual routes and any tRPC specific boilerplate code necessary for setting up a project with the library. Create a file called context.ts
.
tRPC should be initialized exactly once per application with initTRPC
. If you initialize more than one instance of tRPC you could potentially run into issues. By creating only one tRPC instance, we’ll ensure that the tRPC initialization step is encapsulated.
After running .create()
on initTRPC
, we export router
a publicProcedure
variables but without any routes or procedures yet. Since the fundamental unit of a tRPC-based API is a router, we’ll create a routes
directory and hello.ts
file for defining a router called helloRouter
.
A router will contain a procedure that exposes an API “endpoint.” Import publicProcedure
from context.ts
.
Import helloRouter
and create a hello
route by setting helloRouter
to hello
in the router
object.
Run the cURL request again to see the new output. See comment for expected output.
Add Input Validation with Zod
As mentioned in the introduction, Zod is a library for schema declaration and validation. It can create a “schema” that broadly refers to any data type which can be declared with static types in TypeScript. It comes with a handful of built-in primitive types that are commonly used in TypeScript projects:
- Primitive values include
string
,number
,bigint
,boolean
,date
, andsymbol
. - Empty types include
undefined
,null
, andvoid
which acceptsundefined
. any
andunknown
are catch-all types that allow any values.never
does not allow any values.
If you were to query http://localhost:2022/hello
again, the data
object would contain a string with the message hello from the query fallback
. But we can also pass an input
to change the resulting message. Run the cURL request one more time but with a URL encoded string input to see the new output.
What if we wanted to validate something more complex than a single string
? We can change z.string
to z.object
and specify a key-value pair where the key is name
and the value is a string
containing a name.
This time when you run curl
you will receive an array containing a result
object with the key-value pair set to data
.
Since managing all the URL encoding is a huge pain, you can also use a tool like Insomnia or Postman to make HTTP requests with JSON objects as the input.
Final project structure for a standalone Node server.
Create React Client
Create a set of strongly-typed React hooks from your AppRouter
type signature with createTRPCReact
. This initializes a trpc
client instance from @trpc/react-query
which provides a thin wrapper over @tanstack/react-query
.
We’ll be installing the following project dependencies for the client side:
react
- the React core libraryreact-dom
- React’s DOM rendering library@trpc/client
- tRPC’s client implementation@trpc/react-query
- tRPC’s React Query wrapper@tanstack/react-query
- TanStack’s React Query
For development dependencies on the client we’ll be installing:
vite
@vitejs/plugin-react
- Types for
@types/react
and@types/react-dom
Replace your current project scripts with the following:
Your React application, much like your tRPC application, can be organized in any fashion you prefer. For this example, I’m going to structure the files and directories like a Next.js project specifically a project using the pages
directory. This way, we can seamlessly migrate to Next.js in the final next section.
Configure Vite Root Component
Create a file called _app.tsx
inside src/pages
.
Create the following files as well:
vite-env.d.ts
vite.config.ts
index.html
In the vite.config.ts
configuration file, import react
from @vitejs/plugin-react
and set it to plugins
in the defineConfig
object.
The boilerplate HTML page imports a root component from src/pages/_app.tsx
and renders it into a div
element. In the next section, we’ll write the code for our _app.tsx
file.
In _app.tsx
, we will use createRoot
to render
the root
component containing the Index
component. This root component imports and returns an HTML paragraph element from another file which represents our homepage.
The Index
component will return a message saying, Hello from React!
.
The server should still be running with the previous pnpm dev:server
command. Run the following command to start up the development server for the React client.
Open localhost:5173
to see the React app.
Create Tanstack Query Client
Create a components
folder and then a Hello.tsx
component inside that folder.
In Hello.tsx
, we’ll import api
from pages/index
, run useQuery
on api.hello
, and input a key-value pair with the key name
and a value containing a string message.
For this to work we’ll need to initialize the TanStack Query client in index.tsx
. QueryClient
is used to interact with React Query’s cache and QueryClientProvider
connects and provides QueryClient
to the application.
httpBatchLink
is a terminating link that batches an array of individual tRPC operations into a single HTTP request that’s sent to a single tRPC procedure. The terminating link is the last link in a link chain. Instead of calling the next
function, the terminating link is responsible for sending your composed tRPC operation to the tRPC server and returning an OperationResultEnvelope
.
The links
array added to the tRPC client config should have at least one link and that link should be a terminating link. If links
don’t have a terminating link at the end of them, the tRPC operation will not be sent to the tRPC server. At this point you’ll get the following error in your browser console:
Access to fetch at
http://localhost:2022/hello?...
from originhttp://127.0.0.1:5173
has been blocked by CORS policy. Response to preflight request doesn’t pass access control check.No
Access-Control-Allow-Origin
header is present on requested resource. If an opaque response serves your needs, set request’s mode tono-cors
to fetch resource with CORS disabled.
Oh no, CORS! Don’t worry, I won’t make you spend the next half hour reading the Cross-Origin Resource Sharing page on MDN for the 50th time. We’ll create an HTTP handler and set the correct access headers.
Create HTTP Handler
The createContext()
function is called for each request and the result is propagated to all resolvers. You can use this to pass contextual data down to the resolvers.
Simultaneously run the server on localhost:3000
and the client on localhost:5173
with pnpm dev
.
Right now I’m displaying a few pieces of information contained in the data
object that is returned by useQuery
.
The data
object holds a bunch of stuff you may or may not need depending on what you are querying. I’d recommend using a console.log
on the data
object at least once to see all the properties, but here’s a few of the most common you’ll likely use while getting started with tRPC.
Final project structure for fullstack tRPC application with a React frontend and Node backend.
Migrate to Next
We’ve now seen how to create a standalone HTTP server with tRPC and a client application that queries the server with React. Lets use Next.js to migrate our server into API routes and query them with @trpc/next
.
Configure Project for Next
Since Next includes a “server side” and a “client side,” we no longer need separate scripts for tRPC and React. Replace the previous scripts with the following:
Create a file called next-env.d.ts
for Next’s reference types. This file should not be edited, see the Next.js TypeScript documentation for more information.
Configure Vercel Edge Runtime
We no longer need to worry about managing CORS or setting headers on our responses at all since Next.js will now manage that. We can remove everything except the appRouter
and AppRouter
type from src/server/index.ts
since we don’t need to run the server from this file anymore.
Instead of running a standalone Node server, we’ll create an API Route in the src/pages/api
directory called [trpc].ts
.
The api
directory is a Next.js convention for creating a public API that executes backend JavaScript code in an isolated and fully managed environment. In this case, the code to run will be a Node function that makes a fetch request via tRPC’s fetchRequestHandler
.
Set the endpoint
to /api
and the router
to appRouter
. In createContext
an arrow function is run that returns an empty JavaScript object and nothing else. We also set the runtime
to edge
inside our config
object because this example uses the Next.js Edge Runtime via tRPC’s fetch adapter.
Edit the Hello.tsx
component in src/components
.
Import createTRPCNext
from @trpc/next
and initialize the client to a variable called api
. The config
argument is a function returning an object to configure the tRPC client. The returned value includes the links
property to customize the flow of data in tRPC between the client and server.
Lastly, import api
from src/pages/index.tsx
and pass MyApp
to the withTRPC()
higher-order component.
Open localhost:3000
.
Deploy to Vercel
TypeScript decided to yuck my yum at the last minute and gave the following error:
Type error: The inferred type of
api
cannot be named without a reference to.pnpm/@trpc+react-query@10.14.1_ldv44zvqpibs4fctxfwnszfeji/node_modules/@trpc/react-query/shared
. This is likely not portable. A type annotation is necessary.
This blog post is already long enough so instead of doing the responsible thing and fixing this error I’m going to turn off TypeScript build errors with a next.config.js
file.
Set ignoreBuildErrors
to true
and prepare an apology tweet for the thousands of developers you just infuriated.
Install the vercel
CLI as a project dependency and build your project’s output. Then deploy to staging with the --prebuilt
option and finally deploy to production with the --prod
option.
Open ajcwebdev-trpc.vercel.app
to see your deployed app and run the following curl command to send a request to your api
handler. If you followed along correctly and everything worked as intended you should get the commented output: