REST API, tRPC, or GraphQL? What would you choose for your new mobile app startup?

Recently, I decided to bring one of my countless startup ideas to life šŸ¤“, and I got stuck on choosing a communication protocol between the mobile app and the backend. Unusually for me, I chose the path of least resistance and decided not to overthink architecture and so on — just to start building the product. Knowing that it likely won’t ever see the light of day, I didn’t worry much about optimizations, architecture, folder structures, or developer experience. I just wanted to start coding the product in a way that was reasonably convenient for me.

So, in short — this was supposed to be a mobile app with a separate backend. I don’t like building serverless apps because they limit me a bit. Sometimes it’s hard to implement file size/type validators, notification logic, cron jobs, etc. I prefer building a separate backend from the start so that the project is frontend-independent. That way I can change the UI anytime without having to move or rewrite a lot of business logic. But that’s not the point of this story.

REST API - the old way

As usual, I decided to use a REST API. I wrote some standard endpoints using the Hono framework. I could’ve used Express, but since the developer behind Express also created Hono, I figured there was some reasoning behind it. Everything was fine, the backend worked, so I moved on to the mobile app. That’s when problems started appearing. I used react-query as the client to communicate with the backend. Pretty quickly, I realized I was missing generated types. ā€œWriting them manually is really dumb. I don’t see the point in duplicating types when I could just generate them using some toolā€ - I thought.

OpenAPI - "workaround" for type safety

That’s why I decided to integrate my backend with OpenAPI. Luckily, Hono has integration with @hono/zod-openapi, which allows defining endpoints strictly using Zod schemas.

I refactored the backend a bit and got to work on the mobile app. On the frontend, I used the orval library, which parsed the OpenAPI schema and generated types and typed hooks for react-query. That was better, I thought. But the types had weird names like GetUsersResponseOpenApi200 or something like that. I get it — the backend can return different statuses and errors — but for a startup I’m building just to test an idea, that was overkill. I didn’t want to handle every error or add custom statuses to Zod schemas in @hono/zod-openapi, and then deal with a mess of types generated by orval. I didn’t like it — it looked messy. I still wanted to simplify the DX so I could focus more on business logic than type definitions and schema configurations.

OpenAPI Swagger UI

But, it worth mentioning that the integration with Hono and Swagger UI is pretty cool (via @hono/swagger-ui). I could easily test the backend endpoints, see the request/response schemas, and even try out the endpoints directly from the Swagger UI. That was a nice touch. But as a developer, I focus more on the code than the UI. I don’t need a fancy UI to see what my endpoints returns. Types should speak for themselves. So, while the Swagger UI was nice, it didn’t really solve my problem.

tRPC - hyped "workaround" for REST API

At that point, I decided to try tRPC, which would give me powerful typing out of the box without complex configuration or auto-generating types.

Monorepo - the only place where tRPC makes sense

It’s worth noting that I’m working in a monorepo, so I can easily import the main app router type on the frontend. In my setup, though, development became easier. I had to refactor the backend a bit again, but now I know for sure: REST API is dead for me šŸ’€ (*in terms of developing mobile apps). Since tRPC communicates over HTTP, it’s essentially a typed REST API. If you write a backend in tRPC and later want to change the frontend to another language (not TypeScript), you can still make requests to the backend — you just won’t have type safety. But in that case, you’d probably have to define an OpenAPI spec and tweak the backend a bit anyway. I mostly use TypeScript for everything, so I’m not worried about it.

If you’re using separate repos for frontend and backend, you could theoretically still do this — you’d just need to locally link the backend package or set up automatic publishing. But yeah, it’s a bit of extra work and arguably not easier than using OpenAPI.

Additionally, since I’m working in a monorepo, it made sense to create a shared package with Zod schemas to avoid duplicating code. Very convenient — I recommend doing the same.

Enums - missing piece - the downside

However, one small issue remains. I haven’t found a way to share enums between the frontend and backend. I use Prisma, which generates types and enums. But the problem is I can’t use them on the frontend without adding Prisma as a dependency — which I don’t actually use there. As a workaround, I just duplicated the enums into a shared package and used them on the frontend.

Another option would be to create a separate shared package that only exports the generated enums. But that’s a lot of manual, one-time work and a bit of overkill. At least in Prisma version 6.7.0, it now generates separate modules (enums, types, etc.), which might simplify this setup later. But that’s for another time (never).

GraphQL - goat 🐐

Did I stick with tRPC? For now, yes. But I kinda regret not going with my favorite — GraphQL. I gave in to the hype around these modern tools and wanted to try something new — just to once again confirm that GraphQL is the GOAT. I just thought setting up a GraphQL backend would be more complicated than tRPC — which turned out to be true. Also, there’s still no great frontend client with a DX similar to react-query. Sure, there’s apollo-client, but if you’ve ever tried manually updating a cached object (instead of just doing refetchQuery), you know that react-query and apollo-client are poles apart. I personally miss good type inference on the cache operations.

Pros of GraphQL

  • Backend-agnostic
  • Great type generation, typed hooks, and enums (which really makes life easier)
  • Flexibility: I can fetch only the fields I need, not everything
  • Resolvers: I can define a separate resolver for each field, which is super handy. Technically I do the same in tRPC when I have calculated fields. But the downside is, if I call a procedure, I fetch the calculated field even if I don’t need it. I could move it to a separate procedure, but that’s more manual work.

Regarding breaking changes — with GraphQL, we change the schema and add @deprecated annotations. In theory, I’ll do the same with tRPC (haven’t reached that point yet), and the IDE should highlight deprecated methods.

I won’t go over all pros and cons of these approaches — that wasn’t the point of the article. I just wanted to say: don’t overthink it. There’s no perfect solution. You’re better off spending that time writing documentation, a business plan, or doing marketing instead of rewriting your backend for the hundredth time — which you’ll probably rewrite again in a few weeks. Of course, it’s good to understand different approaches, but in solo projects (even commercial ones), it’s often overkill. I believe good typing can sometimes replace good architecture (though that thought probably needs elaboration — but if you get it, you get it). Finding balance is an art we all still need to learn.

Summary

  • REST API — dead end in TS world 🄓. I'm not saying it's obsolete, but it's not convenient for fully-typed monorepos. Yes, you have tons of tools for autogenerating types, even for setting up the entire REST API from PostgreSQL schemas and other cool stuff... But, I'd only use it if I know for sure I’ll be writing the frontend in something other than TypeScript, or if I’ll be writing microservices in different languages. Otherwise, I’d avoid it.

  • tRPC — seems fine, at least for small startups. If you don’t have a ready-made GraphQL backend setup to copy, I’d go with tRPC. Especially since GraphQL might be overkill for your tiny project that you’ll abandon in a few days. And if you’re not using a monorepo, consider either migrating to one or sticking with GraphQL/REST.

  • GraphQL — powerful. Never had real issues with it (aside from manually updating caches in apollo-client — it works, but type safety is awful. In theory, you could try using react-query + graphql-request, and also codegen. I haven’t tried it yet, so maybe I’ll write a short review on that another time). But it’s a bit of a hassle to set up. I just wish there was a better client for it. Besides that, you’ll have to write a lot of boilerplate code and deal with the complexity of setting up the backend. But once you do, it’s great.

So, if you already have a working GraphQL project — just copy it, update the packages, and start your new business. No need to waste time configuring it all from scratch again. I regret not realizing this earlier.

P.S. While writing this article, I barely resisted the urge to rewrite that damn backend to GraphQL instead of tRPC one more time...

P.S. Don't use this article as a guide for your startup. It's just a personal opinion. I don't know what I'm doing, and I don't want to be responsible for your decisions. Just do whatever you think is best for you.

P.S. This "comparison" was written based on mobile app and backend development. For other web projects, the situation might be different.