ProNextJS
    Loading
    lesson

    Backend-for-Frontend (BFF) Architecture with Server Actions

    Jack HerringtonJack Herrington

    Let's look at the server actions variant of the Backend-for-Frontend (BFF) architecture. In this setup, our Next.js application acts as a client-facing frontend and a backend-for-frontend (BFF), communicating with a REST microservice using server actions.

    In this case, we are going to use server actions to talk between the client and the Next.js App Router application inside of the bff-sa directory.

    Understanding the Architecture

    Our application architecture involves three main components:

    • Next.js App (BFF): This acts as a client-facing frontend and a backend-for-frontend (BFF). It handles user interactions, server-side rendering, and communication with the microservice.
    • Server Actions: These functions, running on the server-side within our Next.js application, provide a secure way to interact with the microservice.
    • REST Microservice: This independent service handles the application's core logic and data persistence.
    bff-sa architecture

    Looking at the Code

    Most of the work happens in the homepage RSC at app/page.tsx. We start by fetching the API server location from the environment variables, then the callTodoService helper function is set up to make REST endpoint calls to the microservice.

    One of the important things to note is that we pass cookies from the incoming request to the REST API for authentication. This ensures that the REST API knows who is making the request. Priorities are unauthenticated, while to-dos are authenticated and require a user ID.

    The addTodoAction server action demonstrates the key concepts. It takes in a title, priority, and completed status for a new to-do. It uses the "use server" pragma, gets the authorization locally, then after the POST will revalidate the root path to reflect the latest data.

    The updateCompletion and deleteToDo functions will function similarly:

    import { auth } from "@/auth";
    import { Todo } from "@repo/todos";
    import { revalidatePath } from "next/cache";
    import { headers } from "next/headers";
    
    import AuthButton from "@/components/AuthButton.server";
    
    import TodoList from "./TodoList";
    
    const API_SERVER = process.env.API_SERVER;
    
    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();
    }
    
    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[])
        : [];
    
      async function addTodoAction(
        title: string,
        priority: string,
        completed: boolean = false
      ) {
        "use server";
        const session = await auth();
        if (!session?.user?.id) throw new Error("User not authenticated");
    
        await callTodoService("/todo", "POST", {
          title,
          priority,
          completed,
        });
    
        revalidatePath("/");
      }
    
      async function updateTodoCompletionAction(
        todoId: string,
        completed: boolean
      ) {
        "use server";
        const session = await auth();
        if (!session?.user?.id) throw new Error("User not authenticated");
    
        await callTodoService(`/todo/${todoId}`, "PUT", { completed });
    
        revalidatePath("/");
      }
    
      async function deleteTodoAction(todoId: string) {
        "use server";
        const session = await auth();
        if (!session?.user?.id) throw new Error("User not authenticated");
    
        await callTodoService(`/todo/${todoId}`, "DELETE");
    
        revalidatePath("/");
      }
    
      return (
        <main>
          <AuthButton />
          {session?.user && (
            <TodoList
              todos={todos}
              priorities={priorities}
              addTodoAction={addTodoAction}
              updateTodoCompletionAction={updateTodoCompletionAction}
              deleteTodoAction={deleteTodoAction}
            />
          )}
        </main>
      );
    }
    

    The TodoList Client Component

    Now let's take a look at how the TodoList.tsx component consumes these server actions.

    The TodoList component takes in the priorities, todos, and server actions as props. It manages local state for the new to-do title and priority. The onSubmit, onSetCompleted, and onDelete functions handle interactions with the server actions.

    This direct communication pattern simplifies data management by delegating backend interactions to the server:

    // inside app/TodoList.tsx
    "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,
      addTodoAction,
      updateTodoCompletionAction,
      deleteTodoAction,
    }: {
      priorities: string[];
      todos: Todo[];
      addTodoAction: (title: string, priority: string, completed?: boolean) => void;
      updateTodoCompletionAction: (todoId: string, completed: boolean) => void;
      deleteTodoAction: (todoId: string) => void;
    }) {
      const [priority, setPriority] = useState<string>(priorities[0]);
      const [title, setTitle] = useState<string>("");
    
      const onSubmit = async () => {
        await addTodoAction(title, priority);
      };
    
      const onSetCompleted = async (id: string, completed: boolean) => {
        await updateTodoCompletionAction(id, completed);
      };
    
      const onDelete = async (id: string) => {
        await deleteTodoAction(id);
      };
    
      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>
      );
    }
    
    

    Benefits of This Architecture

    By using server actions and a REST microservice, the Next.js application doesn't need to directly manage data. Instead, it can rely on the microservice as the source of truth. Server actions provide a secure channel for communication, protecting sensitive data and operations.

    Transcript

    This is the server actions variant of the local systems architecture. In this case, we are going to use server actions to talk between the client and the Next.js AppWriter application, BFFSA, but that BFFSA Next.js AppWriter application is then going to send any requests back to a microservice, in this case API REST, and it's going to do that using basic REST requests. Let's go take a look at the code. Most of the real work in this application is going to be right here on the home page RSC. We're going to start by getting the API server location out of environment variables.

    Then we're going to set up this call to do service helper function. This is what's actually going to make the REST endpoint calls to our microservice. It's going to take the URL, the way we're going to call on the microservice, the verb that we're going to use, we're going to default that to get, and then in the case of a request that takes a body, we're going to take any kind of body and send that along. The really important part here is that we're going to pass through the cookies from the request header that's coming into the Next.js application and send those to the REST API. That's how the REST API is going to know who's actually making this specific request.

    We're going to do that by getting the cookie off the header. Down in RSE, when we start processing, we're going to get the current session, that's going to tell us who we are. We're going to call priorities that's unauthenticated. We're also going to call to-dos that would be authenticated. We're only going to call the to-dos endpoint if we are authenticated and we have a session user ID.

    Then we're going to set up some local server actions including adding a to-do. Now, this add to server action is going to take a title, a priority and a completed. It's of course going to be a server action because it's going to use the use server pragma. It's going to get the authorization locally and check to make sure that we are authorized. If we are not, then we're going to throw an error.

    If we are, then we're going to just call that backend service. Now, the important part here is that once we've made a mutation, we want to revalidate the path. Reason being that the Next.js application is no longer the source of truth for what's actually in the to-dos, the microservices. So when we call that microservice, we want to make sure that once it's done with this mutation, we invalidate the local Next.js path so that we, on subsequent requests, go and get new data. We do the exact same thing when we create a server action for clicking on the completion updating as well as deleting a to-do.

    We always invalidate our local path. Finally, we render the app with the off button at the top as well as the to-do list with the current list of to-do's, the priorities, as well as all the server actions that we just created. Now let's go take a look at our to-do list client code. This to-do list takes the priorities as well as a to-do list as well as all of those server actions that we just created. The only thing it needs is local state is the new to-do title and priority.

    Everything else we do is essentially just calling back to those server actions that we just created. We add a to-do action, we just simply call add to-do action, give it a title, it's priority, and that's it. The server action then makes that call back to the REST API endpoint and there it is. This is the simplest variant of the back-end for front-end systems architecture. So if you've got microservices you need to talk to but you want to have your Next.js application be very simple in how it works, this is the pattern that you're going to want to follow.