Build a Public tRPC API: trpc-openapi vs ts-rest

When we decided to build a public-facing API at Documenso, we had to choose between creating a new application from scratch or using the existing codebase.

The first option meant building the API with a technology like Node.js, for example, and launching it under a new, separate domain.

The API would be decoupled from the main Documenso app, which would result in benefits such as low latency responses and supporting larger file uploads, to name a few. So, creating a new app for the API looks like a more robust solution and, therefore, the natural choice.

But there's a problem.

Building the API from scratch means an extra application to deploy and manage. At Documenso, we're still a young company with a relatively small team. Building and maintaining a second application would stretch our resources.

As a result, we decided to go with the second option - to implement the public API in the existing codebase. Later, we can move the API to a separate codebase if required.

So, since we're using tRPC, we had to choose between 2 technologies to build the public API:

  • trpc-openapi
  • ts-rest

In this article, I'll talk about each technology in part, the pros and cons of both, and our choice.

trpc-openapi

I first came across trpc-openapi when the tRPC team recommended it for building public-facing APIs (source).

This package lets you easily create public REST endpoints for your existing tRPC procedures. It also enables you to add OpenAPI support.

Since this solution is more like a plugin or add-on, it’s quicker to implement. The configuration is minimal in the sense that you can turn your tRPC procedure into a REST endpoint as follows:

export const appRouter = t.router({
  sayHello: t.procedure
    .meta({ /* 👉 */ openapi: { method: 'GET', path: '/say-hello' } })
    .input(z.object({ name: z.string() }))
    .output(z.object({ greeting: z.string() }))
    .query(({ input }) => {
      return { greeting: `Hello ${input.name}!` };
    });
});

As a result, trpc-openapi doesn't require a lot of code, and thus, it allows you to move faster. But that’s coming at the expense of having the API implementation as the contract. In some scenarios, having a specific, standalone API contract might be more beneficial, though.

tRPC-OpenAPI pros:

  • Easier and quicker to implement.
  • Not much boilerplate code is required.
  • Solution recommended by the tRPC team.

tRPC-OpenAPI cons:

  • It’s more like an add-on rather than a standalone solution.
  • The private and public procedures can become intertwined.
  • trpc-openapi defines the API implementation as the contract. However, in certain use cases, you might want a separate contract to represent the API (as in our case).

ts-rest

ts-rest allows you to define a standalone contract for your public-facing API.

In this case, an API contract refers to a piece of code that describes the structure of the API, the format of the requests and responses, and how to authenticate your API calls, among others.

Here's a contract example:

const contract = c.router(
  {
    getDocuments: {
      method: 'GET',
      path: '/documents',
      query: GetDocumentsQuerySchema,
      responses: {
        200: SuccessfulResponseSchema,
        401: UnsuccessfulResponseSchema,
        404: UnsuccessfulResponseSchema,
      },
      summary: 'Get all documents',
    }
);

The above API contract defines the structure of an API that only has one method getDocuments. It specifies the type of request it accepts (GET), what the endpoint is, the query parameters it takes, various response formats, and a summary that describes the purpose of the route.

With ts-rest, you can define a contract that can be shared between the client and the backend. For example, that can be a shared library in a monorepo or a shared npm package.

As a result, ts-rest allows you to build a REST-like API with the typical HTTP methods such as GET, POST, PUT, DELETE, etc., with the extra benefit of providing these endpoints to the client as RPC-type calls in a fully type-safe interface.

ts-rest pros:

  • This solution feels more robust since it’s a standalone solution rather than a second abstraction layer.
  • It allows you to design an API in the "REST" style (GET, PUT, POST, DELETE, etc.) but with RPC-type client calls.
  • It provides end-to-end type safety, has an RPC-like client-side interface, and has a small bundle size of 1kb.

ts-rest cons:

  • It requires more boilerplate code to set everything up.
  • It takes more time to implement it initially and add additional routes.
  • You need to build the API from scratch rather than re-using existing routes.

My conclusion is that trpc-openapi seems like the faster solution, while ts-rest seems like the more robust solution. However, one isn't necessarily better than the other. Each has its benefits and drawbacks, and it depends on what you want to build. Weigh both options and see which makes the most sense for your use case.

Our choice

After comparing the two options, we chose the ts-rest option. Being able to create a standalone API contract and design the API in a REST style while providing end-to-end type safety and a small bundle size convinced us to pick ts-rest.

Links:

Each API should have an associated documentation so users can explore the API structure and workings. This article teaches you how to generate an interactive Swagger UI documentation for your Next.js API from an OpenAPI Specification.

💡
This pull request contains the work for building the public API.