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