Getting started with tRPC v10 by building a todo app - Backend

By Zjerlondy Ferero

10 min read

There's nothing quite like the feeling of finally getting your front-end and back-end types to match up. After hours of slacking, emailing, and going through documentation, you finally have it! But that feeling doesn't last long, because you realize the back-end had some type changes, which broke your front-end again! Well, here is where tRPC comes into play.

Getting started with tRPC v10 by building a todo app - Backend
Authors

tRPC is a lightweight library that allows you to create fully typesafe APIs without schemas or code generation. It provides end-to-end typing, which means that types can be shared between the server and client(s). This results in fewer bugs and less boilerplate code.

In this two-part series, we'll be building a todo app using tRPC. In the first part, we'll start by creating a backend API using tRPC, and then in the second part, we'll create a (React Native) frontend UI that consumes the API. By the end of this series, you'll have a fully functioning todo app!

what we are creating

Note: at the time this article was written, tRPC v10 was still in beta; therefore, some of the provided code examples might not work anymore.

Prerequisites

  • (Minimum) Node v8 installed
  • React (Native) development environment
  • Basic React (Native) / Typescript knowledge

We won't be focusing on installing and setting up Typescript in this article; therefore, I've created a Github repo with some basic Typescript boilerplate to get us started right away!

Setting up our Express/tRPC backend

We will use tRPC in conjunction with ExpressJS in this article. tRPC is frequently used in conjunction with Next.js, but for this article, I chose ExpressJS to demonstrate tRPC's power in conjunction with ExpressJS. tRPC has a nice ExpressJS adapter that handles some of the tRPC magic for us, thus making setting up our tRPC backend very easy. Even though we use ExpressJS, tRPC can be used in combination with any (Node) backend that supports Typescript.

Installing dependencies

Let's start by using the terminal to navigate to the server directory in our project directory, and install the following dependencies:

cd server && npm install @trpc/server@next zod

As you can see, we install the @trpc/server package, but also a package named Zod. Zod is a library that makes input validation very simple by using schema-based validation. With Zod, tRPC can validate incoming requests against a predefined schema, keeping our handler function clean and free of any unnecessary validation checks. tRPC has out-of-the-box support for different schema validation tools such as Yup and Superstruct.

Initializing tRPC and creating our first router

Let's start by creating a new file called trpc.ts in the server/src/ of our project folder. In the file, add the following code:

import { initTRPC } from '@trpc/server'

export const t = initTRPC.create()

All this file is responsible for is initializing and exporting a tRPC instance.

Now, let's start by creating, arguably, the most important piece of our back-end, the router. Begin by creating a new file called todo.ts in the server/src/routers/ folder of our project folder. In the todo.ts add the following code:

// Import our tRPC instance
import { t } from '../trpc'

// Initalize an empty array where we will be storing our todo's.
// For now, we will use the type `any[]`, but this will be changed later on.
let todos: any[] = []

// Create our todo router, and add a query procedure (equivalent of a REST Get request) called `all`,
// which will be responsible for returning all the stored todo's
export const todoRouter = t.router({
  all: t.procedure.query(() => {
    return todos
  }),
})

We create a tRPC router by calling the router() method and passing an object containing the different endpoints and their procedures as an argument. tRPC knows two procedures:

  • Query: Equivalent to a REST Get call
  • Mutation: Used for creating, updating, and deleting data. Equivalent to the REST POST, PATCH, PUT, and DELETE calls.

In the code snippet above, we are first importing our tRPC instance, and after, that we are creating a todos array which will be used to store our todos. After creating our todo's array, we create our todoRouter which currently has one query procedure called all, which will return all our stored todo's.

Now, let's create a new file, index.ts in the same server/src/routers/ folder of our project, and add the following code:

// Import our tRPC instance
import { t } from '../trpc'

// Import our todo Router
import { todoRouter } from './todo'

// Create an appRouter which will be used to tie together all our different routers
// In our case, we will only have one router, our todo router. This todo router will be exported under the namespace `todo`.
export const appRouter = t.router({
  todo: todoRouter,
})

// Export only the **type** of a router to avoid importing server code on the client
export type AppRouter = typeof appRouter

As you can see, we start again by importing our tRPC instance, followed by importing our todoRouter. After that, we create a new appRouter which will be used to tie together all our different routers. In our case, this will only be our todoRouter, which is exported under the namespace todo.

Namespaces are used to organize our routes. To consume our API in the front-end, you will make use of the namespaces to identify the correct router. For instance, if you wanted to query the todo router, you would write: todo. followed by the procedure name, in our case: todo.all.

Lastly, we export the type AppRouter, so that we can use it later on in the front-end.

Creating our server

Now that we have created our router, we would want to have a way to make calls to that router, so let's start by creating a Express server. In the server/src folder of our project, we will be creating a new file called index.ts which contains the following code:

// Import the tRPC Express Adatper
import * as trpcExpress from '@trpc/server/adapters/express'

// Import Express
import express from 'express'

// Import our App Router
import appRouter from './routers'

// Initialize Express
const app = express()

// Tell Express to parse incoming requests using JSON
app.use(express.json())

// Tell Express to let the tRPC adapter handle all incoming requests to `/trpc`
app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
  })
)

// Start the server under the port 3000
app.listen(3000)

If you are familiar with Express, the code above is pretty self-explanatory. First, we start by importing the tRPC Express Adapter, Express and finally our appRouter. After that, we initialize Express, and tell Express to use the express.json() middleware to parse incoming requests to JSON. After that, we instruct Express to route all incoming calls to /trpc through a middleware exported from the trpcExpress.createExpressMiddleware() method. This method takes an option object as an argument. In our case, we only pass our appRouter as an option. So, all incoming requests to /trpc will be handled by our appRouter.

Testing our first endpoint

Now that we have created our server and a simple route, it's time to test! We can do this by simply starting our server, hitting the API, and checking the response.

Start by navigating to the project folder in the terminal and running npm run dev. This will start the server on port 3000. Now, open a browser and navigate to http://localhost:3000/trpc/todo.all, which will result in the following JSON response:

{
  "result": {
    "data": []
  }
}

As you can see, we are getting result from the server, but the issue is, the data array is empty. Let's fix that! Let's jump back to our todo.ts file in the server/src/routers directory. If you look at the following line const todos: any[] = [];, you will see that we are initializing todos as an empty array. Let's change that by adding some data: const todos: any[] = ["todo1", "todo2", "todo3"];. Now, go back to your browser and refresh the page. If everything went well, you would see the following response:

{
  "result": {
    "data": ["todo1", "todo2", "todo3"]
  }
}

We have concluded that everything is working fine, but manually altering the todos array is not the way to go, so let's make an API that allows us to create todos!

Creating our first mutation

As mentioned previously, tRPC knows two types of procedures: a query procedure and a mutation procedure. When creating our todo.all procedure, which is responsible for getting data, we used the query procedure.

Let's start by heading back to our todo.ts file in our routers/ directory, and adding a new procedure to our todo router:

export const todoRouter = t.router({
  all: t.procedure.query(() => {
    return todos
  }),
  add: t.procedure.mutation(({ input }) => {
    todos.push(input)

    return todos
  }),
})

As you can see, we added a new mutation procedure called add, and all it's responsible for, is pushing the received input to the todos array and after that, returning the new altered todos array.

Testing our first mutation

To test if this is working, let's open Postman (or any other API testing tool), and simply hit our new todo.add using the following URL: http://localhost:3000/trpc/todo.add. The request body can simply be:

{
  "title": "our first todo"
}

After hitting our new todo.add endpoint, you should've gotten the following response;

{
  "result": {
    "data": [null]
  }
}

As you can see, it's adding null to our data array. This is happening due to us not specifying to tRPC what the body of our incoming request will look like, and therefore tRPC fails to parse the incoming request into usable data. There are several ways we can fix this, but we will fix this by simply declaring a specific input format for our todo.add procedure. Let's jump back to our todo router file, and modify our code to match the following code:

// Import our input validation tool
import { z } from 'zod'

// Import our tRPC instance
import { t } from '../trpc'

// Initalize an empty array where we will be storing our todo's.
// For now, we will use the type 'any[]', but this will be changed later on.
let todos: any[] = []

// Create our todo router, and add a query (equivalent of a REST Get request) procedure with the name `all`
// Which be responsible for returning all the stored todos
export const todoRouter = t.router({
  all: t.procedure.query(() => {
    return todos
  }),
  add: t.procedure
    .input(
      z.object({
        title: z.string(),
      })
    )
    .mutation(({ input }) => {
      todos.push(input)

      return todos
    }),
})

If you look closely at the add procedure, you will notice we added a new method called input(). This input() method takes an input validation schema that defines what our incoming request body should look like. tRPC supports a handful of input validators, but we have decided to use Zod. If you look closely, you can see that all we are doing is telling tRPC that our request body should exist of an object containing a title that is a string. Let's jump back to Postman and hit our todo.add API again to test this out.

As you can see, the data array is now correctly populated:

{
  "result": {
    "data": [
      {
        "title": "our first todo"
      }
    ]
  }
}

Now let's extend our request body object by adding the extra fields: id and completed. The id field will be used to uniquely identify a todo, and the completed field will be used to mark a todo as completed. To keep our todoRouter function clean and simple, I will be moving the add todo request body validation schema to a separate file. Let's create a new folder, models, in our server's src/ directory. This folder will contain one file called todo.ts. This is where we will define our add todo validation schema.

/* server/src/models/todo.ts */

import { z } from "zod";

// Helper function to generate random id's for our todo's
const generateRandomId = () => {
  return Math.floor(Math.random() * 10000 + 1);
};

// Our todo Schema
export const todoSchema = z.object({
  id: z.number().default(generateRandomId),
  title: z.string()
  completed: z.boolean().default(false),
});

// Create a new Todo type using our todo schema
export type Todo = z.infer<typeof todoSchema>;

In the code snippet above, we are leveraging Zod to create a todoSchema. To get a detailed explanation of Zod, I recommend you look at their documentation.

Take note that we are using Zod's z.infer function to create a new Todo type from our todoSchema. z.infer is a really powerful tool you can use to extract Typescript types from Zod schemas.

Now, jump back to our todo router and modify our input() method to use the newly created todoSchema

/* server/src/routers/todo.ts */

// Import our tRPC instance
import { t } from '../trpc'

// Import our todo Schema
import { todoSchema, Todo } from '../models/todo'

// Initalize an empty array where we will be storing our todo's.
let todos: Todo[] = []

// Create our todo router, and add a query (equivalent of a REST Get request) procedure with the name `all`
// Which be responsible for returning all the stored todos
export const todoRouter = t.router({
  all: t.procedure.query(() => {
    return todos
  }),
  add: t.procedure.input(todoSchema).mutation(({ input }) => {
    todos.push(input)

    return todos
  }),
})

Let's open Postman and test our API:

Testing our first mutation

Since we configured a default value when creating our todo schema with Zod, the id and completed fields will automatically be filled.

Creating our update and delete mutation

Let's continue by creating our update and delete mutations. Our delete mutation will be pretty straightforward. All it will do is take a todo's id as input and, using that id, filter out the correct todo from the todos array. Take a look at the updated todoRouter:

// Import Zod
import { z } from 'zod'

// Import our tRPC instance
import { t } from '../trpc'

// Import our todo Schema
import { todoSchema, Todo } from '../models/todo'

// Initalize an empty array where we will be storing our todo's.
let todos: Todo[] = []

// Create our todo router, and add a query (equivalent of a REST Get request) procedure with the name `all`
// Which be responsible for returning all the stored todos
export const todoRouter = t.router({
  all: t.procedure.query(() => {
    return todos
  }),
  add: t.procedure.input(todoSchema).mutation(({ input }) => {
    todos.push(input)

    return todos
  }),
  delete: t.procedure.input(z.number()).mutation(({ input }) => {
    const filteredTodos = todos.filter((todo) => todo.id !== input)

    todos = [...filteredTodos]

    return todos
  }),
})

Pretty easy, right?

Let's move on to our last mutation, the update todo mutation. First, let's look at the updated code:

// Import trpc
import * as trpc from '@trpc/server'

// Import Zod
import { z } from 'zod'

// Import our tRPC instance
import { t } from '../trpc'

// Import our todo Schema
import { todoSchema, Todo } from '../models/todo'

// Initalize an empty array where we will be storing our todo's.
let todos: Todo[] = []

// Create our todo router, and add a query (equivalent of a REST Get request) procedure with the name `all`
// Which be responsible for returning all the stored todos
export const todoRouter = t.router({
  all: t.procedure.query(() => {
    return todos
  }),
  add: t.procedure.input(todoSchema).mutation(({ input }) => {
    todos.push(input)

    return todos
  }),
  delete: t.procedure.input(z.number()).mutation(({ input }) => {
    const filteredTodos = todos.filter((todo) => todo.id !== input)

    todos = [...filteredTodos]

    return todos
  }),
  update: t.procedure.input(todoSchema.partial()).mutation(({ input }) => {
    const index = todos.findIndex((todo) => todo.id === input.id)
    const todo = todos?.[index]

    if (!todo) {
      throw new trpc.TRPCError({
        code: 'NOT_FOUND',
        message: "Given id doesn't exist",
      })
    }

    todos[index] = {
      ...todo,
      ...input,
    }

    return todos[index]
  }),
})

For the update mutation, we will have the request body be the same as the add todo procedure, but this time we will make all the fields optional using Zod's .partial() helper function. Doing this makes it easy for us to partially update todos.

Another interesting thing to look at is the following code:

if (!todo) {
  throw new trpc.TRPCError({
    code: 'NOT_FOUND',
    message: "Given id doesn't exist",
  })
}

If we can't find a todo that matches the provided id, we throw a TRPCError. The TRPCError is a subclass that makes it possible for us to represent an error that occurred inside a procedure. The code parameter will be mapped to an HTTP Error code. E.g. in our case, NOT_FOUND corresponds to HTTP status 404.

Testing our update and delete mutation

Now that we have implemented all our mutations, we can finally test them. Let's start by adding two new todo's using our API.

Testing our APIs: adding todos

Now, let's list them using our todo.all API

Testing our APIs: displaying todos

Now, let's update one of the todo's

Testing our APIs: updating todos

And finally, let's delete one of them

Testing our APIs: deleting todos

Tada 🥳! Everything is working as it should!

Conclusion

In this article, we looked at what tRPC is by building a simple todo API. tRPC is a library that allows you to set up end-to-end typing, which leads to fewer bugs and a better development experience! tRPC can be used in conjunction with many front-end frameworks such as React, React Native, Vue, and even Svelte! For more information, take a look at the tRPC docs. The code used in this article can be found in this Github repo.

In the second part of this two-part series, we will be showcasing the power of having end-to-end typing by consuming our newly created todo API in our React Native frontend!

Thank you for reading!


Upcoming events

  • Mastering Event-Driven Design

    PLEASE RSVP SO THAT WE KNOW HOW MUCH FOOD WE WILL NEED Are you and your team struggling with event-driven microservices? Join us for a meetup with Mehmet Akif Tütüncü, a senior software engineer, who has given multiple great talks so far and Allard Buijze founder of CTO and founder of AxonIQ, who built the fundaments of the Axon Framework. RSVP for an evening of learning, delicious food, and the fusion of creativity and tech! 🚀 18:00 – 🚪 Doors open to the public 18:15 – 🍕 Let’s eat 19:00 – 📢 Getting Your Axe On Event Sourcing with Axon Framework 20:00 – 🍹 Small break 20:15 – 📢 Event-Driven Microservices - Beyond the Fairy Tale 21:00 – 🙋‍♀️ drinks 22:00 – 🍻 See you next time? Details: Getting Your Axe On - Event Sourcing with Axon Framework In this presentation, we will explore the basics of event-driven architecture using Axon Framework. We'll start by explaining key concepts such as Event Sourcing and Command Query Responsibility Segregation (CQRS), and how they can improve the scalability and maintainability of modern applications. You will learn what Axon Framework is, how it simplifies implementing these patterns, and see hands-on examples of setting up a project with Axon Framework and Spring Boot. Whether you are new to these concepts or looking to understand them more, this session will provide practical insights and tools to help you build resilient and efficient applications. Event-Driven Microservices - Beyond the Fairy Tale Our applications need to be faster, better, bigger, smarter, and more enjoyable to meet our demanding end-users needs. In recent years, the way we build, run, and operate our software has changed significantly. We use scalable platforms to deploy and manage our applications. Instead of big monolithic deployment applications, we now deploy small, functionally consistent components as microservices. Problem. Solved. Right? Unfortunately, for most of us, microservices, and especially their event-driven variants, do not deliver on the beautiful, fairy-tale-like promises that surround them.In this session, Allard will share a different take on microservices. We will see that not much has changed in how we build software, which is why so many “microservices projects” fail nowadays. What lessons can we learn from concepts like DDD, CQRS, and Event Sourcing to help manage the complexity of our systems? He will also show how message-driven communication allows us to focus on finding the boundaries of functionally cohesive components, which we can evolve into microservices should the need arise.

    | Coven of Wisdom - Utrecht

    Go to page for Mastering Event-Driven Design
  • The Leadership Meetup

    PLEASE RSVP SO THAT WE KNOW HOW MUCH FOOD WE WILL NEED What distinguishes a software developer from a software team lead? As a team leader, you are responsible for people, their performance, and motivation. Your output is the output of your team. Whether you are a front-end or back-end developer, or any other discipline that wants to grow into the role of a tech lead, RSVP for an evening of learning, delicious food, and the fusion of leadership and tech! 🚀 18:00 – 🚪 Doors open to the public 18:15 – 🍕 Let’s eat 19:00 – 📢 First round of Talks 19:45 – 🍹 Small break 20:00 – 📢 Second round of Talks 20:45 – 🙋‍♀️ drinks 21:00 – 🍻 See you next time? First Round of Talks: Pixel Perfect and Perfectly Insane: About That Time My Brain Just Switched Off Remy Parzinski, Design System Lead at Logius Learn from Remy how you can care for yourself because we all need to. Second Round of Talks: Becoming a LeadDev at your client; How to Fail at Large (or How to Do Slightly Better) Arno Koehler Engineering Manager @ iO What are the things that will help you become a lead engineer? Building Team Culture (Tales of trust and positivity) Michel Blankenstein Engineering Manager @ iO & Head of Technology @ Zorggenoot How do you create a culture at your company or team? RSVP now to secure your spot, and let's explore the fascinating world of design systems together!

    | Coven of Wisdom - Amsterdam

    Go to page for The Leadership Meetup
  • Coven of Wisdom - Herentals - Spring `24 edition

    Join us for an exciting web technology meetup where you’ll get a chance to gain valuable insights and knowledge about the latest trends in the field. Don’t miss out on this opportunity to expand your knowledge, network with fellow developers, and discover new and exciting possibilities. And the best part? Food and drinks are on us! Johan Vervloet - Event sourced wiezen; an introduction to Event Sourcing and CQRS Join me on a journey into the world of CQRS and Event Sourcing! Together we will unravel the misteries behind these powerful concepts, by exploring a real-life application: a score app for the 'Wiezen' card game.Using examples straight from the card table, we will delve into the depths of event sourcing and CQRS, comparing them to more traditional approaches that rely on an ORM.We will uncover the signs in your own database that indicate where event sourcing can bring added value. I will also provide you with some tips and pointers, should you decide to embark on your own event sourcing adventure. Filip Van Reeth - WordPress API; "Are you talking to me?" What if the WordPress API could be one of your best friends? What kind of light-hearted or profound requests would it share with you? In this talk, I would like to introduce you to it and ensure that you become best friends so that together you can have many more pleasant conversations (calls). Wanna be friends? Please note that the event or talks will be conducted in Dutch. Want to give a talk? Send us your proposal at meetup.herentals@iodigital.com 18:00 - 19:00: Food/Drinks/Networking 19:00 - 21:00: Talks 21:00 - 22:00: Networking Thursday 30th of May, 18h00 - 22h00 CET iO Campus Herentals, Zavelheide 15, Herentals

    | Coven of Wisdom Herentals

    Go to page for Coven of Wisdom - Herentals - Spring `24 edition

Share