ProNextJS
    lesson

    BFF Architecture with tRPC

    Jack HerringtonJack Herrington

    tRPC provides a way to handle communication between servers or between a client and a Next.js server. In this lesson, we'll focus on a backend-for-frontend (BFF) architecture using tRPC.

    Our setup will use a Next.js server for the BFF, which will communicate with a REST microservice. The Next.js server will use tRPC to communicate with the frontend, while using REST to talk to the microservice.

    the bff-tRPC architecture

    Let's dive into the code and see how this works.

    Setting Up the tRPC Router

    We'll start with the server-side implementation of our tRPC API.

    Inside the bff-tRPC/src/server directory of our BFF tRPC project, the index.ts file is where we define the tRPC router and its functions.

    The router acts as a central hub for handling API requests. We define a set of functions like getPriorities and getTodos to retrieve data, and addTodo to modify data. Each function corresponds to a specific API endpoint and its associated logic.

    // inside bff-tRPC/src/server/index.ts
    
    import { z } from "zod";
    
    import { publicProcedure, router } from "./tRPC";
    
    import { callTodoService } from "@/todo-api";
    
    const TodoSchema = z.object({
      id: z.string(),
      title: z.string(),
      priority: z.string(),
      completed: z.boolean(),
    });
    
    export const appRouter = router({
      getPriorities: publicProcedure.output(z.array(z.string())).query(async () => {
        return await callTodoService("/prorities");
      }),
      getTodos: publicProcedure.output(z.array(TodoSchema)).query(async () => {
        const todos = await callTodoService("/todos");
        return todos;
      }),
      addTodo: publicProcedure
        .input(
          z.object({
            title: z.string(),
            priority: z.string(),
            completed: z.boolean(),
          })
        )
        .output(TodoSchema)
        .mutation(async (opts) => {
          return await callTodoService("/todo", "POST", opts.input);
        }),
      updateCompleted: publicProcedure
        .input(
          z.object({
            id: z.string(),
            completed: z.boolean(),
          })
        )
        .mutation(async (opts) => {
          return await callTodoService(`/todo/${opts.input.id}`, "PUT", {
            completed: opts.input.completed,
          });
        }),
      deleteTodo: publicProcedure.input(z.string()).mutation(async (opts) => {
        return await callTodoService(`/todo/${opts.input}`, "DELETE");
      }),
    });
    
    export type AppRouter = typeof appRouter;
    

    tRPC leverages Zod for type safety. Zod allows us to define schemas for our data using its straightforward syntax. For instance, the TodoSchema defines the structure of a to-do item. We use Zod to validate both the data coming in from requests and the data being sent back in responses.

    A key part of our BFF setup is the callTodoService function. This function handles communication with our API REST microservice. It's designed to make requests to the microservice and includes logic for handling cookies.

    The callTodoService Function

    The callTodoService can be found in bff-tRPC/src/todo-api.ts:

    import { headers } from "next/headers";
    
    const API_SERVER = process.env.API_SERVER;
    
    export async function callTodoService(
      url: string,
      method: "GET" | "PUT" | "DELETE" | "POST" = "GET",
      body?: any
    ) {
      const req = await fetch(`${API_SERVER}${url}`, {
        method,
        headers: {
          Cookie: headers().get("Cookie")!,
          "Content-Type": "application/json",
        },
        body: body ? JSON.stringify(body) : undefined,
      });
      return await req.json();
    }
    
    

    The callTodoService function takes the location of our API REST server and constructs the request URL. The function then uses the fetch API to make the request. Notice the use of JSON.stringify(body) when sending data to the REST API.

    We extract a cookie called token from the incoming request and include it in the headers of the request being sent to the API REST microservice. This cookie likely contains authentication information (for instance, a JWT), enabling the microservice to identify the user making the request.

    This authentication flow ensures secure communication between the Next.js server and the API REST backend.

    The Homepage

    The homepage doesn't use the tRPC stuff we just created. As a React Server Component (RSC), it directly communicates with our API REST backend using the callTodoService function.

    The homepage is responsible for retrieving data that doesn't require client-side interactivity, like our to-do list and priorities:

    // inside src/app/page.tsx
    
    import { auth } from "@/auth";
    import { Todo } from "@repo/todos";
    
    import AuthButton from "@/components/AuthButton.server";
    
    import TodoList from "./TodoList";
    
    import { callTodoService } from "@/todo-api";
    
    import RQProvider from "@/tRPC/RQProvider";
    
    export default async function Home() {
      const session = await auth();
    
      const priorities: string[] = await callTodoService("/priorities");
    
      const todos: Todo[] = session?.user?.id
        ? ((await callTodoService("/todos")) as Todo[])
        : [];
    
      return (
        <RQProvider>
          <main>
            <AuthButton />
            {session?.user && <TodoList todos={todos} priorities={priorities} />}
          </main>
        </RQProvider>
      );
    }
    

    Notice how the homepage retrieves the list of priorities and to-dos using the callTodoService function. This data is then passed to the TodoList component for rendering.

    The TodoList Client Component

    The TodoList component handles client-side interactions within our app. For any actions that modify data, like adding a new to-do item, it utilizes the tRPC client to communicate with our Next.js server.

    In the code, the TodoList component utilizes tRPC's useQuery to fetch and manage the list of to-dos. It also defines a mutation using useMutation to handle adding new to-do items:

    "use client";
    import { useState } from "react";
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from "@/components/ui/select";
    import { Input } from "@/components/ui/input";
    import { Checkbox } from "@/components/ui/checkbox";
    
    import { Todo } from "@repo/todos";
    import { Button } from "@/components/ui/button";
    
    import { tRPC } from "@/tRPC/client";
    
    export default function TodoList({
      priorities,
      todos: initialTodos,
    }: {
      priorities: string[];
      todos: Todo[];
    }) {
      const [priority, setPriority] = useState<string>(priorities[0]);
      const [title, setTitle] = useState<string>("");
      const { data: todos, refetch: refetchTodos } = tRPC.getTodos.useQuery(
        undefined,
        {
          initialData: initialTodos,
        }
      );
    
      const addTodo = tRPC.addTodo.useMutation({
        onSuccess: () => {
          setTitle("");
          refetchTodos();
        },
      });
    
      const onSubmit = async () => {
        await addTodo.mutate({
          title,
          priority,
          completed: false,
        });
      };
    
      const updateCompleted = tRPC.updateCompleted.useMutation({
        onSuccess: () => refetchTodos(),
      });
    
      const onSetCompleted = async (id: string, completed: boolean) => {
        await updateCompleted.mutate({
          id,
          completed,
        });
        refetchTodos();
      };
    
      const deleteTodo = tRPC.deleteTodo.useMutation({
        onSuccess: () => refetchTodos(),
      });
    
      const onDelete = async (id: string) => {
        await deleteTodo.mutate(id);
        refetchTodos();
      };
    
      return (
        <div className="mt-5">
          {todos && (
            <>
              <ul>
                {todos?.map((todo) => (
                  <li key={todo.id} className="flex gap-2 items-center mb-3">
                    <Checkbox
                      checked={todo.completed}
                      onClick={() => onSetCompleted(todo.id, !todo.completed)}
                    />
                    <div className="flex-grow">{todo.title}</div>
                    <Button variant="destructive" onClick={() => onDelete(todo.id)}>
                      Delete
                    </Button>
                  </li>
                ))}
              </ul>
            </>
          )}
          <div className="flex gap-2">
            <Select value={priority} onValueChange={(v) => setPriority(v)}>
              <SelectTrigger className="w-[180px]">
                <SelectValue placeholder="Priority" />
              </SelectTrigger>
              <SelectContent>
                {priorities.map((priority) => (
                  <SelectItem value={priority} key={priority}>
                    {priority}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Input
              placeholder="Todo"
              value={title}
              onChange={(evt) => setTitle(evt.target.value)}
            />
            <Button onClick={onSubmit}>Submit</Button>
          </div>
        </div>
      );
    }
    

    This separation ensures a clear and maintainable codebase. The client focuses on presentation and user interaction, while the server (our Next.js BFF) handles data fetching, mutations, and communication with the microservice.

    By combining tRPC with a Next.js BFF, we've established a type-safe communication flow between our frontend, backend, and microservices. This setup simplifies our frontend code by abstracting away data fetching and mutations while ensuring type safety across the entire stack.

    Transcript

    TRPC is another way to talk between two servers or to talk between a client and an XJS server. That's what we're going to do in this BFF TRPC variant. So again, this is a backend for front-end architecture. We have some microservices behind us, in this case, API REST. We talk to that microservice using REST, but between the front-end, the actual web client, and the next.js server, BFF TRPC, we use TRPC.

    Let's go take a look at how to do that in the code. So the fun here actually starts over in the server directory under bfftrpc, source server. You can actually see all of the functions that we add on to the TRPC app router. So in TRPC, you create a set of functions, in this case, getPriorities, gets the list of priorities, getTodo's, gets the list of to-dos, addTodo's and so on. TRPC is a strongly typed system, so you use Zod to go and define both the inputs and the outputs.

    So in the case of priorities we say that our output is an array of strings. We have a to-do schema so when it comes to getting a list of to-dos we get an array of that to-do schema. For a mutation or a query with arguments, you're going to take an input, and you use .input for that, and then you give it a Zod definition of your input. In the case of adding a to-do, we're going to take a title, a priority, and a completed, and we're going to mark that also as a mutation. Now, all of this is going to call the to-do service.

    So what does that look like? Well this call to-do service function takes the location of our API REST server and then it takes three arguments. The URL that we're gonna call, the method that we're gonna call on it, defaulting to get, and then in the case of a post or a put, we're going to have a body. So that's going to be the body that we're going to send to the API REST. This function basically just wraps fetch.

    The only addition here is that we're going to add the cookie. So the idea here is that the cookie that has a jot is going to get sent from the client, it's an HTTP only cookie, that's going to go to the Next.js server. The Next.js server, when it makes calls to the API REST backend, is going to take that cookie that it gets off the request and add it to the request going to the API REST endpoint. That way, the API REST endpoint knows who it's talking to. Of course, this is all unbeknownst to our TRPC functions, which just call to-do service with the corresponding URL method and any body arguments which in this case is the title priority and completed as we are posting a new to-do to our back end.

    All right now let's go take a look at our home page. Now our home page because it's an RSC is not going to use the TRPC stuff that we just created. It instead is just going to talk directly to the API backend using that call to do service. So right at the top, we're going to get our session. We're going to call the unauthenticated priorities to get the list of priorities.

    Then if we are logged in, that's really all we're checking for. We're going to get the to-dos by calling that to-do service, and we're going to pass that all off to to-do list in the JSX. Let's take a look at our client component. Now, our client component is going to use TRPC for any mutations. So down here on line 16 it's going to import that TRPC client.

    It's only going to get the initial set of to-dos from our RSC. It can of course get the priorities, those are immutable. In terms of state it's going to have the priority as well as the state. But then it's going to use getTodo's useQuery. So TRPC is based on top of hand stack React query.

    So it's going to get a hook for getTodo's. So that's going to give us our proxied through slash todo's route. And it's going to set up the initial data to say, OK, before we get any new todo's, we have an initial data set, which is our initial todo's. Then we use tRPC addTodoUseMutation to get back an addTodo object. We call mutate on that addTodo object to actually make the mutation.

    When we add a to do we reset the title and then we refresh the to dos using the use query that we got from get to dos. Then we invoke that add to do mutate when you click on the submit button to create a new to do. And that's it. That's what it takes to have TRPC between the client and the next JS server and where the next JS server in turn talks to the microservice backend through REST. Now the nice thing here is because the backend for frontend is a TRPC backend for frontend.

    And that means that if you've got other TRPC-enabled clients like a mobile app or a desktop app or a CLI, those can use this strongly typed TRPC interface to make requests against the Next.js server. Of course, they would need to set that authorization cookie correctly in order to make that happen, but still it's a well understood API.