If you are a web developer you might have heard of tRPC within the last few months. It's a relatively new way of building typesafe APIs and works especially well with Next.js.

I've recently open-sourced one of my side projects, Hilde, which also was my first project to try out tRPC. And, to summarize my experience: I really like it. It took some time until it finally clicked, but after that working with it was a breeze. It drastically improves development speed and offers an unmatched developer experience.

After working with it for some time now I've discovered different aspects of like - aspects I like, aspects I think that could be improved, and aspects I dislike. In this post, we're going to talk about these aspects.

💡
This was post was written at the time of tRPC v10. I've never used tRPC standalone and exclusively used it in conjunction with Next.js. Until today I've only used it for (multiple) private side projects.

Onboarding

I remember when I first heard about tRPC on Twitter I initially had no idea what it was all about. The website states "Move Fast and Break Nothing. End-to-end typesafe APIs made easy." - maybe I'm just a little dumb, but I had no idea what this means at first glance and I've been doing this web stuff for quite some time now.

"Fast" is an argument that is thrown around in this business like corruption scandals in the Austrian government and "break nothing" honestly didn't appeal like the absolute game changer because, realistically, things should not break before tRPC already, right?

The website also shows some code that makes a lot of sense by now - but back when I first encountered it just wasn't enough to grab the gist of this library.

And I'm not the only developer that was confused about it at first. Many of my developer colleagues had no real idea about what all of this is about - and even people like Theo, the guy behind the T3 stack which is built upon tRPC, had some problems with this at first.

I ultimately solved this problem by simply playing around with it, which helped me to get a real understanding of this library. I'm not sure if I'd have done that without all the hype surrounding it - but retrospectively I can only recommend this approach if you're struggling in the beginning.

Learning curve & documentation

After I decided to give it a try it was time to dive into the documentation to get a better understanding of all its concepts. What's a router? What are procedures? What's the context for? And so on.

Unfortunately - in my opinion - the documentation doesn't give all the answers you might want to see. Looking at the Quickstart documentation the first block looks like this:

Screenshot from the v10 tRPC Docs

Do I need to install a package to call procedures on client? Do I need react-query? I mean, I've worked with and love react-query - but do I need it here? If so, why?

It might be due to the fact that I initially didn't really understand what this library was all about - but the Quickstart certainly didn't help with this in the first few paragraphs.

As mentioned before I ultimately ended up installing everything and just figuring out what every component is for by myself. It surely works this way - it's just a bit annoying.

Server-Side Rendering

One thing that really confused me was the documentation about SSR. From the documentation:

To enable SSR just set ssr: true in your createTRPCNext config callback.
Alternatively, you can leave SSR disabled (the default) and use SSG Helpers to prefetch queries in getStaticProps or getServerSideProps.

Again, it might be on me, but I had no idea what that meant. Does SSR no longer work when using tRPC? Why is it disabled by default? One of the reasons I like to use Next.js is SSR?!

Since the Quickstart docs state:

We highly encourage you to check out the example apps to learn about how tRPC is installed in your favorite framework.

I hoped to find an answer there. But, ironically, the "recommended" example app even enables ssr. So, it's disabled by default but recommended to enable it? What?

Understanding this took a decent amount of time and research. The real key here was to learn about Next.js's getInitialProps and how it works. Since this post is about my experience with tRPC and not a detailed explanation of how it works, I'll try my best to summarize this is a briefly as possible:

If ssr is enabled, tRPC will use getInitialProps (which happens to be a data fetching method, just like getServerSideProps) in order to execute queries before the page is rendered. Contrary to getServerSideProps getInitialProps runs on client and server, depending on how you've reached a page. For example: if you enter the URL and hit enter it'll run on the server. If you navigate to a page via next/link it will run on the client. While this may sound a bit complicated, it actually results in a great user experience, since you can partially load data to improve performance, while still maintaining SEO.

Unfortunately, enabling SSR makes it impossible to use getServerSideProps, which ultimately will lead to one of the biggest downsides of tRPC - but more on that a little later.

Disabling ssr causes the queries to be run client-only, resulting in having less impact on SEO.

For my recent open source project, Hilde, I've simply enabled ssr because I don't need getServerSideProps there. In another project I'm currently working on I had to take some make compromises in order for everything to work.

The "epiphany"

After I finally managed to set everything up I simply started playing around with the library. And yes, while it took some time to understand everything to a degree that I was relatively sure about it clicked at some point - and, oh boy, did it amaze me.

What I realized at this point is that tRPC allows me to build APIs within Next.js a lot faster - while staying completely type-safe. Seems like this is the "move fast and break nothing" part that I initially completely missed. Maybe this should have been clear to me after I first looked at the website, but it wasn't. Blame it on my sometimes slow brain - but in the end, I figured it out which is the only thing that counts.

Let's look at the most prominent use cases:

Querying

Imagine you implement an endpoint in your API as a simple function like

const users = [{ id: 1, name: "John" }, { id: 2, name: "Doe" }];

function getUsers() {
  return users;
}
Simple endpoint function to get all our users

Before tRPC querying this endpoint would require generics in order to know the type that is returned from the endpoint:

// Unfortunately we don't know what type data is!
const { data } = useQuery("users", () => axios.get("<api url>/getUsers"));

data?.forEach(user => user.id); // errors, we don't know if there's an `id` property on our user

// But we can use generics in order to know the type
const { data } = useQuery<Array<{ id: number, name: string }>>("users", () => axios.get("<api url>/getUsers"));

data?.forEach(user => user.id); // works, since our generic helps TypeScript to understand the type returned by our API

While generics kinda solve this issue it's still no great developer experience, due to heavy redundancy and error proneness.

And here's where the magic of tRPC kicks it:

const { data } = trpc.users.getUser.useQuery();

// Console log all user IDs
data?.forEach(user => console.log(user.id));
data might be undefined since the query could still be loading (which can easily be solved by utilizing react-querys tooling like isLoading)

Without having to add generics or anything at all data is now fully typed - and we have all the features that react-query provides on top of that. If we'd now change our endpoint to return identifier instead of id TypeScript would trigger an error, since id does no longer exist on our endpoint.

tRPC knows that we have an endpoint for users (which is represented as a router in the terminology of tRPC), an endpoint getUsers and also knows the types returned by this endpoint. It does all of that by applying a decent amount of magic (aka TypeScript) and makes developers life a lot easier by doing so.

Mutations

And it's not just querying endpoints. If you are familiar with react-query - which helps a lot when you fiddle around with tRPC - you surely have heard of mutations that are used for performing insert/update/delete operations on APIs (or, in other words, requests that should be manually triggered with some kind of input). In tRPC an endpoint to add a user might look like this:

const users = [{ id: 1, name: "John" }, { id: 2, name: "Doe" }];

export const userRouter = t.router({
  addUser: t.procedure
    .input(
      z.object({ name: z.string().min(3) })
    )
    .query(async ({ input }) => {
      users.push({ id: users.length + 1, name: input.name });
      return users;
     }),
})

While this might look a bit overwhelming at first, it's actually really simple:

  1. t is our tRPC client (or, in other words, our way of using the tRPC library) in this example.
  2. router is a simple helper to define a tRPC router. A tRPC router can be seen as a single API (like users in our example above).
  3. procedures are simple functions that represent a single endpoint in our API. They are executed before queries and allow for things like authorization, etc.. You might have a procedure called protectedProcedure that checks if a request comes from an authorized user and throw an error otherwise, or check if a provided token is valid. In our example, the procedure does nothing like that and allows everyone to query that endpoint.
  4. input defines what parameters this endpoint accepts (which is just name in our case). On top of that tRPC natively supports input validation with libraries like zod that makes it possible to validate input before the query is executed. In our case the query is only executed if we receive a name parameter that is a string and at least three characters long.
  5. Our query has access to everything we've defined in input and is also fully typed. For example, input.age would not work, because our Input method doesn't specify an age parameter.

Utilizing this endpoint works just like querying, except it's called useMutation instead of useQuery - and it's also fully typed from the beginning:

const addUserMutation = trpc.users.addUser.useMutation();

// This works, because our mutation knows its input and `name` is valid
addUserMutation.mutate({ name: "foobar" });

// This doesn't work, because `name` must be a string
addUserMutation.mutate({ name: 3 });

And more

tRPC provides even more like:

  • tools for organizing code by splitting/merging routers, so that you can split a single endpoint into multiple files
  • a request context that every query has access to, which is great if you for example want to get the requests users quickly or similar
  • data transformers that make it easy to use things like superjson (which you definitely should use if you use Next.js and want to work with Dates)
  • or even things like trpc-openapi which implements OpenAPI for your tRPC app.

And some things I haven't even tried yet (like server-side calls or output validation).

I18n complicates things

While being able to quickly implement APIs while staying completely type-safe is great thing I've unfortunately stumbled upon some things that aren't that great, like adding i18n to a tRPC project.

As mentioned earlier the usage of ssr breaks getServerSideProps - but libraries like next-i18next, a decent library for adding i18n support to Next.js, requires getServerSideProps. As of today, it seems like fixing this is out of hand of tRPC maintainers which makes this situation even worse.

My current workaround for using i18n with tRPC is to make use of the SSG Helpers provided by tRPC within getServerSideProps to execute queries server-side only while still having access to getServerSideProps for i18n to work. I've implemented a bunch of helpers myself to make my life easier in this regard. If you want to a have detailed guide on that please let me know in the comments!

Ultimately, having i18n support with tRPC makes things a bit more complicated - but there are workarounds.

Uploading files

Unfortunately, tRPC doesn't support any other content type then application/json which makes file uploads a bit tedious - which is to say on top of the already pretty bad experience when trying to upload files with Next.js.

While you still can implement custom endpoints for uploads with API routes for example you lose all the advantages like procedures, contexts, and other features from tRPC. That ultimately leads to having different implementations in your API, where some endpoints might use tRPC and others are custom implemented (and may do all the tRPC stuff in a different way).

For smaller files, the suggested solution of using base64 encoded files is probably sufficient - but you probably don't want to upload files that are bigger than avatar pictures.

The one-man-show aspect

While I have a lot of respect for KATT, the creator of tRPC, for his creative and tremendous amount of work regarding tRPC there's one aspect not to forget: a library maintained by a single person always adds some kind of risk to your project.

There's no evidence that tRPC will disappear within the next days - but it's essentially still "just" a side-project from a single developer and there's no guarantee that it will be around in the future or that some things will be added/fixed/changed, which always should be considered when thinking about adding a library it to a project.

But again, I don't think it's going to disappear any time soon - especially since it's really hyped at the moment. It's still a relatively young project and hopefully has a long and great future ahead.

Conclusion

I hope I could give an idea of what my tRPC experience and thoughts have been so far. The question left to answer is: am I moving fast and breaking nothing?

Undoubtedly, I'm a lot faster now when it comes to implementing type-safe API endpoints. Previously I used libraries like next-connect or similar which are great - but the developer experience of tRPC is just unmatched in my opinion.

Do I "break nothing"? Well, my APIs are now type-safe in a way I couldn't imagine before. If "break nothing" is meant by that: I'm a lot less likely to break something, indeed. Otherwise, I'm still occasionally implementing bugs into my software which sadly means that tRPC hasn't miraculously made me some kind of god developer.

Admittedly, getting started with the library was kinda difficult for me and there are undoubtedly things that could be improved - but once you got used to it it's probably one of the best developer experiences I had with any API library so far.