ProNextJS
    Loading
    lesson

    BFF Architecture with GraphQL

    Jack HerringtonJack Herrington

    This time we'll look at how to build a Backend for Frontend (BFF) using GraphQL within a Next.js application. This architecture is particularly useful when you have a GraphQL-based microservice architecture but prefer to avoid exposing GraphQL directly to the client.

    Our setup involves a client, a BFF GraphQL Next.js App Router application, and the GraphQL backend. The client interacts with the BFF via REST, which then uses GraphQL to communicate with the api-gql backend. This separation offers a layer of security, mitigating vulnerabilities associated with exposing GraphQL endpoints publicly.

    the bff-gql architecture

    Code Walkthrough

    Let's break down the code and see how this architecture comes together.

    Homepage

    On our homepage, we begin by initializing a client and a context for our Todo API. We retrieve the session from NextAuth and make a single GraphQL request to fetch both priorities and ToDos:

    // inside bff-gql/src/app/page.tsx
    
    import { gql } from "@apollo/client";
    
    import { auth } from "@/auth";
    import { Todo } from "@repo/todos";
    
    import AuthButton from "@/components/AuthButton.server";
    
    import TodoList from "./TodoList";
    
    import { client, getContext } from "@/todo-api";
    
    export default async function Home() {
      const session = await auth();
    
      const { data } = await client.query({
        query: gql`
          query {
            getPriorities
            getTodos {
              id
              title
              priority
              completed
            }
          }
        `,
        context: getContext(),
      });
      const priorities: string[] = data.getPriorities;
      const todos: Todo[] = data.getTodos;
    
      return (
        <main>
          <AuthButton />
          {session?.user && <TodoList todos={todos} priorities={priorities} />}
        </main>
      );
    }
    

    The Todo API

    The Todo API code is inside of todo-api.ts. We initialize an Apollo client, which connects to our API GraphQL backend at the /graphql endpoint. We're using a context to inject the cookie containing the NextAuth authorization token:

    // inside todo-api.ts
    
    import { headers } from "next/headers";
    
    import { ApolloClient, InMemoryCache } from "@apollo/client";
    
    export const client = new ApolloClient({
      uri: `${process.env.API_SERVER}/graphql`,
      cache: new InMemoryCache({
        resultCaching: false,
      }),
    });
    
    export function getContext() {
      return {
        headers: {
          Cookie: headers().get("Cookie")!,
        },
      };
    }
    

    Remember, for this example app the JWT is unencrypted for illustrative purposes. In a production environment, you should use a more secure JWT implementation!

    The TodoList Component

    Our ToDo list component is a client-side component that tracks internal state as well as interacting with REST API routes on the Next.js server to fetch, create, update, and delete ToDos:

    // inside 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: 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 routes handle communication between the client-side component and the API GraphQL backend.

    The todos Route

    We'll start by looking at the todos route that fetches the list of ToDos. The Apollo client is used to execute a GraphQL query to fetch the ToDos for the current user that's stored in the context:

    // inside api/todos/route.ts
    
    import { NextResponse } from "next/server";
    
    import { gql } from "@apollo/client";
    
    import { client, getContext } from "@/todo-api";
    
    export async function GET() {
      const { data } = await client.query({
        query: gql`
          query {
            getTodos {
              id
              title
              priority
              completed
            }
          }
        `,
        context: getContext(),
      });
    
      return NextResponse.json(data.getTodos);
    }
    

    The todo Route

    Inside of api/todo/route.ts, we have the todo route that handles creating, updating, and deleting ToDos. The route uses the Apollo client to execute GraphQL mutations to interact with the API GraphQL backend:

    import { revalidatePath } from "next/cache";
    import { NextResponse } from "next/server";
    import { gql } from "@apollo/client";
    
    import { client, getContext } from "@/todo-api";
    
    export async function POST(req: Request) {
      const body = await req.json();
      const { data } = await client.mutate({
        mutation: gql`
          mutation ($title: String!, $priority: String!) {
            addTodo(title: $title, priority: $priority) {
              id
              title
              priority
              completed
            }
          }
        `,
        variables: { title: body.title, priority: body.priority },
        context: getContext(),
      });
      revalidatePath("/");
      return NextResponse.json(data.addTodo);
    }
    

    The todo/[id] Route

    The API route for working with specific ToDos is defined in api/todo/[id]/route.ts. It handles updating the completion status and deleting a specific ToDo:

    import { revalidatePath } from "next/cache";
    import { NextResponse } from "next/server";
    import { gql } from "@apollo/client";
    
    import { client, getContext } from "@/todo-api";
    
    export async function GET(
      req: Request,
      { params: { id } }: { params: { id: string } }
    ) {
      const { data } = await client.query({
        query: gql`
          query ($id: ID!) {
            getTodoById(id: $id) {
              id
              title
              priority
              completed
            }
          }
        `,
        variables: { id },
        context: getContext(),
      });
    
      return NextResponse.json(data.getTodoById);
    }
    
    export async function PUT(
      req: Request,
      { params: { id } }: { params: { id: string } }
    ) {
      const body = await req.json();
      const { data } = await client.mutate({
        mutation: gql`
          mutation ($id: ID!, $completed: Boolean!) {
            updateTodoCompletion(id: $id, completed: $completed) {
              id
              title
              priority
              completed
            }
          }
        `,
        variables: { id, completed: body.completed },
        context: getContext(),
      });
      revalidatePath("/");
      return NextResponse.json(data.updateTodoCompletion);
    }
    
    export async function DELETE(
      req: Request,
      { params: { id } }: { params: { id: string } }
    ) {
      await client.mutate({
        mutation: gql`
          mutation ($id: ID!) {
            removeTodoById(id: $id)
          }
        `,
        variables: { id },
        context: getContext(),
      });
      revalidatePath("/");
      return NextResponse.json({ success: true });
    }
    

    Notice how we use revalidatePath("/") after each mutation to invalidate the Next.js cache, ensuring data consistency.

    This is the architecture to use if you have a GraphQL backend.

    Transcript