Skip to content

Exploring tRPC with Server-first UI Frameworks

My curiosity

If you’re a front-end engineer you’re likely to see RPC (Remote Procedure Call) popping up more and more, if you haven’t already. You’ll probably hear people talking about tRPC, create-t3-stack, Telefunc, or other RPC-related tools and libraries. You might also hear the term “front-end back-end” thrown around, and see people talking about newer “meta-frameworks” like Solid Start, Remix, or Next13. I’ve been noticing it and can’t help but wonder if they are related, or fit together in some way. It’s the reason that I wanted to write this post. So let’s dig in and try to figure out what’s what!

The players

RPC and tRPC

For the sake of this post, I’m going to use tRPC as the guinea pig since it is currently the most popular RPC implementation that I’m aware of. Just like almost everything else in computer science, RPC has been around since the dawn of time.

The basic idea is that a client can call remote functions over a network. That concept sounds pretty simple and easy to grok, but we’ll dive into some of the mechanics shortly. Since TypeScript is able to run on both the client and server, tRPC provides both client and server libraries, and leverages the RPC concept to provide type-safe function calls over the client-server network boundary. Seems pretty powerful and just scanning the docs, it looks like a pretty fantastic developer experience.

The “front-end back-end” and server-first web frameworks

There is a clear trend heading back toward server-first web frameworks. Another practice that has been around since the dawn of the web. I will use Remix as my guinea pig here since they seem to have been sharing the good news the loudest. The core idea here is that we are trending away from SPAs and render-as-you-fetch patterns that require heavy client-side JavaScript bundles, and introduce loading spinner waterfalls into UIs.

Remix includes server-side loader/action functions in their framework that are tied to particular routes or pages. They are functions that run on the server that you can use to fetch data from an API or database, before or while sending the page to the user’s browser.

This is where the term “front-end back-end” comes from. These data loaders feed your front-end and act in a similar way to GraphQL resolvers. It’s a place on the server to offload all of your data-fetching and parsing for a web page. There are a lot of new frameworks popping up right now that lean into this pattern: Solid Start, SvelteKit, NextJS 13, Rakkas, and coming soon, TanStack Start.

Comparing the patterns

On the surface, RPC and data loaders seem very similar. They are essentially just functions that can be invoked over the network, most common with HTTP requests. They can both live in the same codebase, and have mechanisms for inferring types from the server-client boundary. Data loaders use more REST or page-bound HTTP requests, and tRPC follows the RPC specification. They both support CRUD operations through their own respective mechanisms.

The main difference that I see is in the ergonomics of the two patterns. Using the tRPC client, you need to explicitly make a call to your procedure from somewhere in your client or server application. With the data-loaders pattern, your function gets called as part of the request for a particular page or REST endpoint.

The nitty-gritty

Now that we know the players, the question is: can they play on the same team? If you look through the tRPC documentation, it’s clear that it has a very nice client-side story, especially its React and React-Query integrations.

But what about from a server-side data loader?

First, we need to wire up tRPC in our framework of choice. In this case, its Remix.

Hooking up tRPC to a Remix app

Not having a lot of tRPC experience, I found this part to be a little bit confusing. tRPC maintains integrations with NextJS, but it seems that you need to really internalize the docs and all the pieces to know how to properly set it up with any other framework. I found a trpc-remix library that made the process pretty easy, and I read through the source code to get a better idea of exactly what’s needed to accomplish it on your own. The important piece is having an adapter that works for whatever server runtime your framework happens to be using.

Adding a simple query and mutation procedure

For demo purposes, I’m just gonna use a simple tRPC router from their documentation that includes a single query and mutation to our backend database (an array).

export const appRouter = router({
  userById: publicProcedure
    .input((val: unknown) => {
      if (typeof val === 'string') return val;
      throw new Error(`Invalid input: ${typeof val}`);
    })
    .query((req) => {
      const input = req.input;
      const user = userList.find((it) => it.id === input);
      return user;
    }),
  userCreate: publicProcedure.mutation((req) => {
    const id = `${Math.random()}`;
    const user: User = {
      id,
      name: req.input.name,
    };
    userList.push(user);
    return user;
  }),
});

Loading data on the client

We always have the option in a React component to fetch data from our tRPC server on the client. Since we have the React Query integration using trpc-remix, we can query and mutate our data the same way that they do in the Getting Started page of the tRPC docs.

export default function Index() {
  const userQuery = trpc.proxy.userById.useQuery('1');
  return (
    <div>
      <p>{userQuery.data?.name}</p>
    </div>
  );
}

Loading data on the server

Now getting to the fun part. How does it work if we want to load data from our tRPC server from a data loader that is also located on the server?

Well, one option is using the tRPC client to make an HTTP request to our server in the same way that our client would.

export const loader = ({ request }: LoaderArgs) => {
	const user = await trpc.getUser.query('1');
  return json({
		user,
	});
};

This doesn’t feel ideal though. If our tRPC procedures are on the server and our loader is also on the server it would make more sense if we could just invoke it directly. Luckily this is possible and tRPC has a documentation page about the topic.

trpc-remix has a trpcLoader utility that returns an instance of our tRPC router that we can make server calls against. So this actually ends up being a pretty decent experience. We can make a direct call to our getUser procedure from our server loader.

type Loader = typeof loader;

export const loader = async (args: LoaderArgs) => {
  const trpcServer = trpcLoader(args)
  const user = await trpcServer.userById('1')
  return json({
    user
  });
};

We can now update our page component to use Remix’s built-in useLoader hook.

export default function Index() {
  const { user } = useLoader<Loader>()
  return (
    <div>
      <p>{user?.name}</p>
    </div>
  );
}

Mutations from action handlers

We could use trpcLoader to invoke our mutation procedures directly from action handlers as well. A form submission is a good example use case.

import { createPost } from "~/models/post.server"

export const action = async ({ request }: ActionArgs) => {
  const trpcServer = trpcLoader(args)
  const formData = await request.formData();

  const name = formData.get("name");
  const user = await trpcServer.userCreate({ name: 'Frodo' });

  return json({ user });
};

Mutations from the client

Mutations generally spawn from user interaction on the client, so unless you are submitting a form the old-fashioned way, your mutation is happening over the wire. For this purpose, we can use our tRPC client like you would in any other tRPC based application.

export default function Index() {
  const { user } = useLoader<Loader>()
  const userCreator = trpc.proxy.userCreate.useMutation();
  return (
    <div>
      <p>{user?.name}</p>

      <button onClick={() => userCreator.mutate({ name: 'Frodo' })}>
        Create Frodo
      </button>
    </div>
  );
}

Things get a little bit trickier when we start talking about mutations from the client when we are using loaders to fetch our component data on the server like we are doing here.

If we were using tRPC exclusively on the client, we could tell react-query to invalidate the cache for any queries that our mutation might have affected. Since we are loading data on the server in Remix, we need to reach for tools in our framework to run our loaders again from the client.

import { useRevalidator } from "react-router-dom";

export default function Index() {
	let revalidator = useRevalidator();
  const { user } = useLoader<Loader>()
  const userCreator = trpc.proxy.userCreate.useMutation();
	const handleClick = () => {
		userCreator.mutate({ name: 'Frodo' }, {
			onSuccess: revalidator.revalidate
		})
	}
  return (
    <div>
      <p>{user?.name}</p>

      <button onClick={handleClick}>
        Create Frodo
      </button>
    </div>
  );
}

In this example, we use useRevalidator from react-router to re-fetch our loader data. This way, once our mutation completes successfully, {user?.name} will properly reflect the updated value.

Epilogue

It feels like tRPC is more equipped as a tool for building APIs to power the SPA and client-heavy applications. The integration with a full-stack framework didn’t feel very simple and straightforward to me. That being said, once you have correctly integrated tRPC in your application, its API does support invoking your server procedures directly without having to make an HTTP request. If you enjoy the ergonomics of tRPC for building your application API, nothing is stopping you from using it with your full-stack TypeScript framework of choice. It has the added bonus of having an amazing client library for when you want to fetch data, or handle a mutation exclusively on the server.

I think tRPC is another fantastic option for building APIs for your TypeScript applications in a world dominated by REST and GraphQL APIs. I’m happy for the opportunity to explore, and learn that it is still a completely viable choice outside of SPA TypeScript apps. There is a ton of innovation happening in this area right now with new frameworks and RPC libraries. Some of them will integrate with one another. React is planning on building RPC right into the framework to support mutations with server components. I’m excited to see how it all pans out, and hope for an amazing combination of user and developer experience.