ProNextJS
    Loading
    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