In this example of the BFF pattern, the client is talking to the Next.js server through either server actions or REST, but the Next.js server is in turn talking to its microservice backend through gRPC. We've chosen to use TwirpScript, a gRPC equivalent, to simplify the communication between the frontend and backend services.
TwirpScript provides a more developer-friendly way to work with gRPC, while having essentially the same architecture. Let's explore how we can set up our Next.js server to communicate with the microservice backend using TwirpScript.
Code Walkthrough
The code for this example is in the bff-twirp
directory.
Homepage
There are two different gRPC services, one for priorities and one for ToDos. Both services are reside on the api-twirp
server. The associated functions are in the repo/twirp-protos
package, which is shared between the Next.js application and the API for Twirp.
Like before, client headers are set, and authentication is handled. When authorized, we can make tRPC calls to interact with our microservices.
import { auth } from "@/auth";
import { Todo } from "@repo/todos";
import { revalidatePath } from "next/cache";
import { client } from "twirpscript";
import { headers } from "next/headers";
import { GetPriorities } from "@repo/twirp-protos/priorities";
import {
GetTodos,
AddTodo,
DeleteTodo,
UpdateTodo,
} from "@repo/twirp-protos/todos";
import AuthButton from "@/components/AuthButton.server";
import TodoList from "./TodoList";
const API_SERVER = process.env.API_SERVER;
client.baseURL = API_SERVER;
function setClientHeaders() {
client.headers = {
Cookie: headers().get("Cookie")!,
};
}
export default async function Home() {
const session = await auth();
setClientHeaders();
const prioritiesReq = await GetPriorities({});
const priorities: string[] = prioritiesReq.priorities;
const todosReq = await GetTodos({});
const todos: Todo[] = todosReq.todos;
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");
setClientHeaders();
await AddTodo({
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");
setClientHeaders();
await UpdateTodo({
id: todoId,
completed,
});
revalidatePath("/");
}
async function deleteTodoAction(todoId: string) {
"use server";
const session = await auth();
if (!session?.user?.id) throw new Error("User not authenticated");
setClientHeaders();
await DeleteTodo({
id: todoId,
});
revalidatePath("/");
}
return (
<main>
<AuthButton />
{session?.user && (
<TodoList
todos={todos}
priorities={priorities}
addTodoAction={addTodoAction}
updateTodoCompletionAction={updateTodoCompletionAction}
deleteTodoAction={deleteTodoAction}
/>
)}
</main>
);
}
The TodoList.tsx
Component
The TodoList
component is agnostic about gRPC or tRPC. It receives the priorities, to-dos, and a set of functions representing server actions as props:
"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>
);
}
This is one of the cleanest approaches to building a BFF with a gRPC architecture, and is highly recommended if you're planning to use gRPC in your future projects.