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.
 
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.
