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.