skip to content

Migrating to tRPC

Published on • 5 min read

software

Next.js 13 was when it first clicked for me. You get the data, render it, and then send HTML to the client. Mutations? Just a function that runs server side. So why spend the better part of a month getting rid of it all? Should you?

First, we should realise that Next.js is tailored for static marketing pages, e-commerce stores, and news sites. This is where the first response from the server must contain content someone (or a bot) is looking for. There may be a few forms or actions that need to run on the server, and for the most part, things aren’t that dynamic. But I’m building a web app - one behind a login screen with tables and lots of moving parts.

A gut feeling

After finishing the foundation of our app with all major features, things didn’t feel right. Business logic was all over the place, fast refresh wasn’t fast, and I kept needing to revalidate paths or do a full router refresh to show the latest data.

Server actions must also be treated like any other API endpoint. So I was constantly repeating the same code over and over again. Validate the input, authorise the user, get the user’s profile etc. It was like a square peg in a round hole.

Choosing an alternative

The most important factors in my decision were:

  1. End to end type safety
  2. A great developer experience once set up
  3. Robustness for our customers
  4. Support for a mobile app and public-facing API in the future

GraphQL would be overkill for a team of one. A local first architecture using Replicache was unnecessary and would’ve been a bigger migration effort. So tRPC with Turborepo and the OpenAPI extension ticked all the boxes.

I like the simplicity of useSWR over TanStack Query, so trpc-swr was a welcomed find. nuqs is also highly recommended if you’re going down this path.

The migration

Getting the initial structure in place was challenging. Even with some great templates, there are always nuances with existing projects. The general concept was easy enough to grasp, but I was surprised by the number of moving parts. My only advice is to keep persevering as the result is worth it. You won’t need to think about it again once it’s done.

On the other hand, moving the code over went smoothly. I started with something simple like the profile page to get a feel for things. Then, all new features used tRPC. Gradually I refactored existing features so everything was using tRPC. The first few features were exciting, and towards the end, it was like a challenge to remove everything. The beauty was that both technologies could exist coherently.

The only issue I’ve come across was batch links - specifically the streaming version. It’s a promising feature, but unfortunately, I keep running into timeout issues. Until I have some time to investigate further, I’ve kept the single link for now.

The result

  • All procedures have context and middleware, making it easy to reference the current user and ensure they are authorised.
  • All procedures have validation using zod, so I can safely access inputs during runtime.
  • Moving my API away from Next.js or Vercel is simply a case of setting up a new server to host the tRPC router, and changing the URL in my front end. So there’s less vendor lock-in.
  • The app generally feels more robust. No more random hydration errors. And with everything going through useSWR, errors are isolated and can be handled gracefully.
  • The standard approach to development will be easier for new team members to follow. Even I struggled to follow my approach of a data access layer before.
  • Fast refresh works as you don’t need to make the same database calls and render the entire component on the server again. 1
  • Local build time reduced from ~1.5min to 30sec.

Why still use Next.js?

I might not be using all the features Next.js has to offer, but those that I am are still great. Routing is easy, and the option for server-side code in certain locations is proving useful. Here’s an example of a reports feature:

// app/(dashboard)/reports/layout.tsx
import { redirect } from 'next/navigation';
import { getCurrentUser } from '~/lib/server/auth';
import { AccessLevel, hasPermission, Resource } from '~/lib/shared/rbac';
export const metadata = {
title: 'Reports'
};
export default async function Layout({
children
}: {
children: React.ReactNode;
}) {
const user = await getCurrentUser();
if (!hasPermission(user, Resource.REPORTS, AccessLevel.FULL)) {
redirect('/home');
}
return <>{children}</>;
}
// app/(dashboard)/reports/page.tsx
import { ReportPage } from '~/features/reports';
export default function Page(){
return <ReportPage />
}

While the tRPC procedure for reports has the same protection, this saves curious eyes from even seeing what type of reports we provide. If you’re taking this approach, I recommend using nextjs-toploader to ease the slight delay between navigation.

Summary

Was it worth it? Absolutely. Would I do it again? Yes. Am I about to migrate the rest of our projects over to it? No.

If you’re trying to build an MVP as quickly as possible, your app is small, and you’re just going to remain web-based, server actions still have their merit. They pair nicely with useSWR, and next-safe-actions will solve a lot of the middleware/context problems. My only advice would be:

  • Use server actions for both mutations and queries, avoiding server components. It’ll make revalidating data much easier.
  • Don’t use barrel files. I believe this was the source of my slowing development experience as they can’t be tree shaken properly.

If you’re building a React Native mobile app, looking to reduce vendor lock-in, or have a medium-sized team, then tRPC is a clear choice.

Footnotes

  1. I’m not 100% certain this is how fast refresh works when using server components. But It feels faster regardless.