ProNextJS
    lesson

    The API Variant of Backend-for-Frontend Architecture

    Jack HerringtonJack Herrington

    Let's look at the API variant of the BFF architecture, where the client uses a REST API located on the BFF API app to make mutations to their to-do list. The BFF API app, in turn, makes requests to the API REST microservice, which interacts with the To-Do's database.

    bff-api architecture

    This example app can be found in the apps/bff-api directory.

    Code Implementation

    Let's start with our homepage.

    We'll first fetch the user's session. If there's no session, we redirect to the sign-in page. Otherwise, we fetch the priorities and to-dos for the user. This is done using the CallTodoService function:

    // inside bff-api/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";
    
    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 (
        <main>
          <AuthButton />
          {session?.user && <TodoList todos={todos} priorities={priorities} />}
        </main>
      );
    }
    

    callTodoService

    The callTodoService function is inside the file apps/bff-api/src/todo-api.ts.

    It is a simple wrapper for fetch. It gets the API server URL from the API_SERVER environment variable:

    // inside bff-api/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 trick here is in the authorization. We take the cookie from the incoming request to the Next.js server and proxy it through to the API server. This allows the API REST server to know who it's talking to. It can use the same JWT decoding algorithm that's used by NextAuth to get the user ID out of the JWT.

    Note that for these examples, we have removed the encryption part of NextAuth. You will not want to do that in production! This just makes it easier for us to have a Next.js application talking to an Express application and sharing the same JWT.

    The TodoList Client Component

    Now let's look at the client component at apps/bff-api/src/TodoList.tsx.

    The Client component gets the list of priorities and to-dos. It keeps track of changes to the to-do list using state and interacts with the API to update, mark as complete, or delete to-dos.

    "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";
    
    export default function TodoList({
      priorities,
      todos: initialTodos,
    }: {
      priorities: string[];
      todos: Todo[];
    }) {
      const [todos, setTodos] = useState<Todo[]>(initialTodos);
      const [priority, setPriority] = useState<string>(priorities[0]);
      const [title, setTitle] = useState<string>("");
    
      const updateTodos = async () => {
        const res = await fetch("/api/todos", {
          cache: "no-cache",
        });
        setTodos(await res.json());
      };
    
      const onSubmit = async () => {
        await fetch("/api/todo", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            title,
            priority,
            completed: false,
          }),
        });
        updateTodos();
      };
    
      const onSetCompleted = async (id: string, completed: boolean) => {
        await fetch(`/api/todo/${id}`, {
          method: "PUT",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ completed }),
        });
        updateTodos();
      };
    
      const onDelete = async (id: string) => {
        await fetch(`/api/todo/${id}`, {
          method: "DELETE",
        });
        updateTodos();
      };
    
      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>
      );
    }
    

    API Routes

    The api directory houses our API routes. The auth directory handles NextAuth authentication, while the todo and todos directories manage interactions with the To-Do microservice.

    Getting To-Dos

    Here's the api/todos route. It handles GET requests, fetches the authenticated user's to-dos using the CallTodoService, and returns them as JSON:

    import { NextResponse } from "next/server";
    
    import { callTodoService } from "@/todo-api";
    
    export async function GET() {
      return NextResponse.json(await callTodoService("/todos"));
    }
    

    Mutation Handlers

    Let's take a look at how to create a todo in api/todo/route.ts.

    This route handles POST requests, extracts the to-do data from the request body, calls callTodoService to create the to-do in the microservice, and revalidates the / path to ensure the cache is updated:

    import { revalidatePath } from "next/cache";
    import { NextResponse } from "next/server";
    
    import { callTodoService } from "@/todo-api";
    
    export async function POST(req: Request) {
      const body = await req.json();
      const out = await callTodoService("/todo", "POST", body);
      revalidatePath("/");
      return NextResponse.json(out);
    }
    

    Inside the api/todo/[id]/route.ts route, we have routes for getting a single to-do, marking a to-do as complete, and deleting a to-do.

    import { revalidatePath } from "next/cache";
    import { NextResponse } from "next/server";
    
    import { callTodoService } from "@/todo-api";
    
    export async function GET(
      req: Request,
      { params: { id } }: { params: { id: string } }
    ) {
      return NextResponse.json(await callTodoService(`/todo/${id}`));
    }
    
    export async function PUT(
      req: Request,
      { params: { id } }: { params: { id: string } }
    ) {
      const body = await req.json();
      const out = await callTodoService(`/todo/${id}`, "PUT", body);
      revalidatePath("/");
      return NextResponse.json(out);
    }
    
    export async function DELETE(
      req: Request,
      { params: { id } }: { params: { id: string } }
    ) {
      const out = await callTodoService(`/todo/${id}`, "DELETE");
      revalidatePath("/");
      return NextResponse.json(out);
    }
    

    When to Use the BFF API Pattern

    This BFF pattern is especially beneficial if you have other clients besides your web app, like mobile apps, desktop apps, or CLIs, that need to interact with your API. This pattern provides a dedicated layer for each client type.

    Transcript

    This is the backend for frontend API variant. This is where the client uses a REST API located on the BFF API app to go and make mutations to their to-do list. The BFF API app in turn makes requests to the API rest microservice, which in turn talks to the to-dos database. Let's go take a look at this in code. Starting off with our homepage, as we always do, we're going to get our session.

    Then we're going to call our to-do microservice to get both the priorities as well as the to-dos for the current user. Then down in the JSX, we're going to render that off button as well as the list of to-dos if we're logged in. Now let's go take a look at the implementation of that call to-do service. So call to-do service takes three arguments. First is the URL, where do you want to call on that microservice?

    Second is the method, in this case the defaults to get. And then a body if you're doing a post or a put. This is really just a wrap around fetch. We're going to call the API server. We get that from an environment variable called API server.

    That's actually set up for you in the.env file, API server. The trick here is in the authorization where we take the cookie from the incoming request to the Next.js server, and then we proxy it through to the API server by just getting the cookie out of the header and sending that along. That allows the API REST server to know who it's talking to. It can use the same JWT decoding algorithm that's used by NextAuth to get the user ID out of the JWT. Now, it's really important to understand here, in these examples, we have removed the encryption part of NextAuth.

    You will not want to do that in production. That's just to make it easier for us to have a Next.js application, talking to an Express application, and sharing the same JWT. Now we know how a call to do service looks, Let's go take a look at the Client component. Our Client component gets the list of priorities as well as the initial to-dos. It retains its own copy of the to-dos as state, as well as the new to-do title and priority.

    Now, we've got a handy helper function called UpdateToDos. It calls API to-dos to get the most recent list of all the to-dos, and that's called by our mutation handlers. Like onSubmit that uses API to-do to post a new to-do, at the end of that it does our update to-dos to get the new list. OnSetCompleted does a put, and onDelete calls a delete. Now, these are where the client is talking to the Next.js server.

    This is not where the Next.js server is talking to the microservice. To look at that, we're gonna take a look over in our API directory. In the API directory, we've got the auth directory that handles NextAuth. We've also got the todo and todos directory. Let's go take a look at our todos.

    This is the slash API slash todos route. It takes a get. We're just going to use that to call that todo service. That todo service is going to get our auth. That's what's going to turn us into a dynamic route.

    Then we're going to get back the data and send it back as JSON. Let's take a look at some mutation handlers. Let's take a look at how to create a to-do over in slash API slash to-do slash route.ts. We handle a post request, we get the body of the JSON, and then we call that to-do service on the slash to-do with a post with that body. The really important part here is in the revalidation of the path.

    We're using that service to handle all the mutations as well as the current known good state of the data. So we need to make sure that when we make changes to that data, that we revalidate and invalidate our local cache inside of Next.js. That's what we're doing with doing revalidate path. Inside the bracket ID route, we've got the atomic getting of a single to do. We actually don't use that in the UI, but there for you if you want to see how that's done.

    And then we've got our mutations where you change the completion status on a put as well as deleting to do when you click that button. This is another common variant of an XJS App Router systems architecture. This is the kind of thing you're going to want to do if you've got other things other than just your web client that want to talk to this BFF API. Those can include a mobile app or a desktop app or a CLI, all kinds of variants.