Get NextJet

API

Learn how to create end-to-end type-safe APIs with tRPC and why it's a superior choice over Next.js API routes, server actions, and native fetch.

tRPC (TypeScript Remote Procedure Call) is a framework designed for creating end-to-end type-safe APIs. It simplifies writing API endpoints that can be safely used on both the front and backend of your application.

tRPC works flawlessly with Tanstack Query as well, a powerful asynchronous state management and data fetching library that simplifies fetching data and managing cache. We use this combination to create a seamless developer experience when working with APIs, tRPC for the backend and Tanstack Query for fetching data on the frontend.

Once you become familiar with tRPC, you can rapidly develop type-safe APIs, significantly enhancing the developer experience (DX) and making development faster and more enjoyable.

Why use tRPC over Next.js API routes, server actions and native fetch?

tRPC offers a lot of benefits over using Next.js API routes, server actions and native fetch:

  1. Automatic Type Safety
    You only need to write your Typescript types once on the server and then they will automatically be inferred on the client.
  2. Autocompletion
    Using tRPC feels like having an SDK for your API's server code, this gives you an excellent overview of your available API endpoints without having to memorize endpoint URLs. When you invoke the API, you get autocompletion for the available routes aswell as arguments and the response.
  3. Separation of Server Logic
    With tRPC, you can place all server logic in a dedicated server directory, keeping it separate from your frontend code. We store our API routes in the packages/api directory, which makes it easy to locate and manage them.
  4. Avoiding Tight Coupling
    Using server actions binds your logic too closely with Next.js. tRPC prevents this, ensuring your code remains flexible and adaptable to different frameworks.
  5. Framework Agnostic
    If you ever decide to move your backend from Next.js to another framework, you can bring your API routes with you. tRPC is not tied to Next.js, making it easy to switch to another framework without having to rewrite your API routes.
  6. Maintainable API Structure
    tRPC enables you to structure your API in a way that is easy to maintain. It simplifies the onboarding process for new team members by providing clear organization and separation of concerns.
  7. Improved Code Organization
    By colocating related code, tRPC makes it easy to locate and manage specific functionalities. This leads to cleaner code and better separation of concerns without code pollution.
  8. Enhanced Developer Experience
    Once familiar with tRPC, you can quickly create end-to-end type-safe APIs. The developer experience (DX) is significantly improved, making development faster and more enjoyable.
  9. Scalability
    tRPC scales well with larger projects. Its clean structure promotes code reusability and colocation, making it easier to manage as the project grows.

Vocabulary

Going forward we will be using the following terms, so it's important to understand what they mean:

TermDescription
ProcedureAPI endpoint - can be a query, mutation, or subscription.
QueryA procedure that gets some data.
MutationA procedure that creates, updates, or deletes some data.
RouterA collection of procedures (and/or other routers) under a shared namespace.
ContextStuff that every procedure can access. Commonly used for things like session state and database connections.
MiddlewareA function that can run code before and after a procedure. Can modify context.
Validation"Does this input data contain the right stuff?"

Folder Structure

The API routes are stored in the packages/api/src directory. The api package contains all the API routes and procedures.

Some developers write all their API procedures in a single file, but this can lead to a messy and hard-to-maintain codebase. Instead, we organize our API routes into separate files and folders, grouping related procedures together.

Don't interact with the database directly in the router/controller. This can become a huge mess as your application grows

Instead, we adhere to the service, controller (router), repository pattern. This pattern is widely used in software development and is known for its clean structure and separation of concerns.

  • The repository is the data access layer, responsible for fetching data from the database.
  • The service is the business logic layer, responsible for handling business logic and processing data.
  • The router is the controller layer, responsible for routing requests to the appropriate service.

The api package is structured as follows:

user.router.ts
index.ts
root.ts
trpc.ts
  • trpc.ts: Sets up and configures the tRPC API. We setup public, protected, and admin procedures here.
  • root.ts: Here, we define and link all the routers for your application. This centralizes the organization and management of all API routes in one location.
  • index.ts: Exports for setting up tRPC with your client and helpers to automatically infer types from your routers.
  • routers: All routers are stored in this directory. Each router contains a group of procedures under a shared namespace.
  • user: Contains the user router, which groups all procedures related to user management. Such as fetching user profiles, updating user information, and deleting users.
  • repository: The data layer for a specific router. Responsible for fetching data from the database.
  • service: The business logic layer for a specific router. Responsible for handling business logic and processing data.

Protecting API Routes

It's very easy to protect your API routes with tRPC. You can use middleware to check if the user is authenticated before allowing access.

I have already set up middlewares in the packages/api/src/trpc.ts file for the following scenarios:

  • Public: Accessible to everyone, even if you are not logged in.
  • Protected: Only accessible to authenticated users.
  • Admin: Only accessible to authenticated users with the admin role.

You can add more middlewares for different scenarios, such as checking if the user has a specific role or permission. But for most cases, the public, protected, and admin middlewares should be sufficient.

Here's an example of how to protect API routes using the middlewares:

user.router.ts
import {
  createTRPCRouter,
  publicProcedure,
  protectedProcedure,
  adminProcedure
} from "../../trpc"

export const userRouter = createTRPCRouter({
  getAll: publicProcedure.query(({ ctx }) => {
    // Procedure logic
  }),
  getMe: protectedProcedure.query(({ ctx }) => {
    // Procedure logic
  }),
  getEmails: adminProcedure.query(({ ctx }) => {
    // Procedure logic
  }),
})

Creating API Routes with tRPC

Below, I'll show you how to create API routes using tRPC.

All our routers are stored in the packages/api/src/routers directory. Navigate to this directory and create a new folder for your router, e.g., comment.

Create the router

Inside this router folder, create a new file for your procedures, e.g., comment.router.ts. This file will contain all the procedures related to comments, such as fetching comments, adding comments, and deleting comments.

comment.router.ts
import { createTRPCRouter } from "../../trpc"

export const commentRouter = createTRPCRouter({
  // Define your procedures here
})

Define the procedures

Inside the router file, define your procedures.

In this example, we define a get comments query that fetches all comments and a add comment mutation that adds a new comment.

The get all comments query should be accessible to all users, even if you are not logged in. The add comment mutation should only be accessible to authenticated users.

comment.router.ts
import {
  createTRPCRouter,
  publicProcedure,
  protectedProcedure,
} from "../../trpc"
import { createCommentInput } from "./service/comment.input"

export const commentRouter = createTRPCRouter({
  getAll: publicProcedure.query(({ ctx }) => {
    // Procedure logic. You can access the ctx here.
  }),
  create: protectedProcedure.input(createCommentInput).mutation(({ ctx, input }) => {
    // Procedure logic. You can access the input data and ctx here.
  }),
})

Inside the procedure logic, you can access:

  • ctx, which is the context and contains information about the request, such as headers, user session, the prisma database client, and more.
  • input, which is the data sent with the request (think params & body). Input can be used both for queries and mutations. For example, the comment text in the add comment mutation.

Inside the input function, you can define the input schema using Zod. This ensures that the input data matches the expected schema. You can define the input schema directly in the input function but I recommend defining it in a separate file for reusability.

Define the Zod input validation schema (optional)

If you have input data for your procedures, you can define the input validation schema using Zod in a seperate file.

Create a new folder inside the user router folder called service and inside that folder create a new file called comment.input.ts.

comment.input.ts
import { z } from "zod"

export const createCommentInput = z.object({
  comment: z.string().min(1).max(100),
})

export type CreateCommentInput = z.infer<typeof createCommentInput>

Write the repository logic

Create a new folder inside the comment router folder called repository. This folder will contain the data layer for the comment router. Create a new file called comment.repository.ts inside the repository folder.

Inside this file we can create a class with methods to fetch data from the database. You might think why use a class? This is preference but the main reason is that it allows us to define private and public methods, private methods can only be accessed within the class and are great for creating helper methods that are only used within the class.

comment.repository.ts
import { db } from "@package/db"
import type { CreateCommentInput } from "../service/comment.input"

class CommentRepository {
  public getAll() {
    return db.comment.findMany()
  }

  public async createComment(data: CreateCommentInput) {
    await db.comment.create({
      data: {
        comment: data.comment,
      },
    })
  }
}

export const commentRepository = new CommentRepository()

Write the service logic

Now that we have the repository logic, we can create the service logic. Inside the service folder create a new file called comment.service.ts.

Inside this file we can create a class with methods to handle the business logic for the comment router. This is where we can call the repository methods and perform any additional logic.

comment.service.ts
import { commentRepository } from "../repository/comment.repository"
import type { CreateCommentInput } from "./comment.input"

class CommentService {
  public getAll() {
    return commentRepository.getAll()
  }

  public async createComment(input: CreateCommentInput) {
    await commentRepository.createComment(input)
    // Additional logic here
    return true
  }
}

export const commentService = new CommentService()

Call the service methods in the router

Now that we have defined the service logic, we can call the service methods in the matching procedures in the router. Go back to the comment.router.ts file we created earlier and call the service methods in the procedure logic.

comment.router.ts
import {
  createTRPCRouter,
  publicProcedure,
  protectedProcedure,
} from "../../trpc"
import { createCommentInput } from "./service/comment.input"
import { commentService } from "./service/comment.service"

export const commentRouter = createTRPCRouter({
  getAll: publicProcedure.query(({ ctx }) => {
    return commentService.getAll() 
  }),
  create: protectedProcedure.input(createCommentInput).mutation(({ ctx, input }) => {
    return commentService.createComment(input) 
  }),
})

Connect the sub-router to the root app router

Up until now we have created the comments router, the procedures, as well as the repository and service logic for it. Finally, we need to connect the comments router to the root app router to make it accessible.

Open the /src/root.ts file and import the comments router. Then add it to the app router.

root.ts
import { commentRouter } from "./routers/comment/comment.router"

export const appRouter = createTRPCRouter({
  comment: commentRouter, 
  // additional routers here
})

Conclusion

Congratulations! You have successfully created two new procedures using tRPC as well as implementing the service, controller, repository pattern.

This pattern of creating API routes does require some extra initial work to setup but it pays off in the long run, because it makes your codebase more organized and maintainable. It separates the concerns of the data layer, business logic layer, and controller layer, making it easy to manage and update your API routes.

You can now use these API routes in your frontend application. Below, I'll demonstrate how to invoke these API routes in both client-side and server-side components.

From here on, you will see the true power of tRPC and how seamless it is to call these API routes in your frontend application while also benefiting from the automatic type safety and autocompletion that tRPC provides.

Sending Errors in Procedures

To handle errors consistently, you can use the TRPCError class. Whenever an error is thrown in a procedure (API route), it will be caught and sent as a response with the appropriate status code.

import { TRPCError } from "@trpc/server"

public async getUserProfileById(args: GetUserProfileByIdArgs) {
  const user = await adminUserRepository.getUserById(args.input.userId)

  if (!user) {
    throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }) 
  }

  return user
}

The code property is used to identify the error type, tRPC provides a list of common error codes that you can use.

tRPC will also automatically throw validation errors if the input data doesn't match the zod schema defined for the procedure.

I have configured the frontend to automatically display a toast message whenever an error occurs in the src/lib/trpc/react.tsx file.

Calling API Routes

tRPC works perfectly with Tanstack Query, a powerful asynchronous state management and data fetching library that simplifies fetching data and managing cache.

It's already set up in the frontend to work with tRPC, so you can start using it right away.

Inside src/lib/trpc on the marketing and dashboard app you will find the following files:

  • react.tsx: Setup for tanstack query and a tRPC client to call the API procedures in client components.
  • server.ts Creates a callable API client to call the API procedures in server components. This basically allows you to invoke procedures as a function instead of http requests.

Keep in mind that you can only use Tanstack Query in client components.

Client-Side Usage

To call API procedures in client components, you can use the api object provided by tRPC.

There are two instances of the api, one for the client and one for the server. Make sure to import the correct one from @/lib/trpc/react

If we were the call the getAll procedure from the comment router in a client component, we would do it like so:

Comments.tsx
"use client"

import { api } from "@/lib/trpc/react"

export function Comments() {
  const commentsQuery =  api.comment.getAll.useQuery() 

  if (commentsQuery.isLoading) {
    return <Spinner />
  }

  return (
    <section>
      {commentsQuery.data.map((comment) => (
        <Comment key={comment.id} comment={comment} />
      ))}
    </section>
  )
}

I like to encapsulate the API calls in custom hooks to keep my components clean and reusable. In this instance you could create a custom hook called useComments.ts that fetches the comments and returns the query object.

Inside the useQuery function you can pass input data to the procedure if it requires it. You can also pass an options object with options to customize the query. You can read more about the options you can pass to the useQuery function in the Tanstack Query documentation.

Likewise, if we wanted to call the create procedure from the comment router in a client component, we would do it like so:

AddComment.tsx
"use client"

import { api } from "@/lib/trpc/react"

type AddCommentProps = {
  comment: string
}

export function AddComment({comment}:AddCommentProps) {
  const commentsMutation =  api.comment.create.useMutation() 

  function handleAddComment(comment: string) {
    commentsMutation.mutate({ comment }) 
  }

  return <button onClick={() => handleAddComment(comment)}>Add Comment</button>
}

Inside the useMutation function you can pass an options object with options to customize the mutation. You can read more about the options you can pass to the useMutation function in the Tanstack Query documentation.

Server-Side Usage

To call API procedures in server components, you can use the api object provided by tRPC.

There are two instances of the api, one for the client and one for the server. Make sure to import the correct one from @/lib/trpc/server

If we were the call the getAll procedure from the comment router in a server component, we would do it like so:

Comments.tsx
import { api } from "@/lib/trpc/server"

export async function Comments() {
  const commentsQuery =  await api.comment.getAll() 

  return (
    <section>
      {commentsQuery.data.map((comment) => (
        <Comment key={comment.id} comment={comment} />
      ))}
    </section>
  )
}

Invalidating the Cache and Refetching Data

If you're familiar with Tanstack Query, you know that you can tag your queries with query keys to manage the cache. Query keys are used to identify and group queries, making it easier to invalidate the cache or refetch data.

With tRPC tagging queries is done for you automatically, instead you just need to call the invalidate function on the route you want to invalidate.

Say you want to invalidate the cache for fetching comments when the user creates a new comment. You can do this by calling the invalidate function on the route you want to invalidate.

In the below example we are invalidating the cache and then refetching the data for the getAll comments route when the user creates a new comment.

import { api } from "@/lib/trpc/react"

const utils = api.useUtils() 

const addCommentMutation = api.comment.create.useMutation({
  onSuccess: async () => {
    await utils.comment.getAll.invalidate() 
  },
})

Now everywhere in your app where you are using the getAll query to fetch comments, the cache will be invalidated and the data will be refetched automatically.

Last updated on