tRPC provides a way to handle communication between servers or between a client and a Next.js server. In this lesson, we'll focus on a backend-for-frontend (BFF) architecture using tRPC.
Our setup will use a Next.js server for the BFF, which will communicate with a REST microservice. The Next.js server will use tRPC to communicate with the frontend, while using REST to talk to the microservice.
Let's dive into the code and see how this works.
Setting Up the tRPC Router
We'll start with the server-side implementation of our tRPC API.
Inside the bff-tRPC/src/server
directory of our BFF tRPC project, the index.ts
file is where we define the tRPC router and its functions.
The router acts as a central hub for handling API requests. We define a set of functions like getPriorities
and getTodos
to retrieve data, and addTodo
to modify data. Each function corresponds to a specific API endpoint and its associated logic.
// inside bff-tRPC/src/server/index.ts
import { z } from "zod";
import { publicProcedure, router } from "./tRPC";
import { callTodoService } from "@/todo-api";
const TodoSchema = z.object({
id: z.string(),
title: z.string(),
priority: z.string(),
completed: z.boolean(),
});
export const appRouter = router({
getPriorities: publicProcedure.output(z.array(z.string())).query(async () => {
return await callTodoService("/prorities");
}),
getTodos: publicProcedure.output(z.array(TodoSchema)).query(async () => {
const todos = await callTodoService("/todos");
return todos;
}),
addTodo: publicProcedure
.input(
z.object({
title: z.string(),
priority: z.string(),
completed: z.boolean(),
})
)
.output(TodoSchema)
.mutation(async (opts) => {
return await callTodoService("/todo", "POST", opts.input);
}),
updateCompleted: publicProcedure
.input(
z.object({
id: z.string(),
completed: z.boolean(),
})
)
.mutation(async (opts) => {
return await callTodoService(`/todo/${opts.input.id}`, "PUT", {
completed: opts.input.completed,
});
}),
deleteTodo: publicProcedure.input(z.string()).mutation(async (opts) => {
return await callTodoService(`/todo/${opts.input}`, "DELETE");
}),
});
export type AppRouter = typeof appRouter;
tRPC leverages Zod for type safety. Zod allows us to define schemas for our data using its straightforward syntax. For instance, the TodoSchema
defines the structure of a to-do item. We use Zod to validate both the data coming in from requests and the data being sent back in responses.
A key part of our BFF setup is the callTodoService
function. This function handles communication with our API REST microservice. It's designed to make requests to the microservice and includes logic for handling cookies.
The callTodoService
Function
The callTodoService
can be found in bff-tRPC/src/todo-api.ts
:
import { headers } from "next/headers";
const API_SERVER = process.env.API_SERVER;
export 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();
}
The callTodoService
function takes the location of our API REST server and constructs the request URL. The function then uses the fetch
API to make the request. Notice the use of JSON.stringify(body)
when sending data to the REST API.
We extract a cookie called token
from the incoming request and include it in the headers of the request being sent to the API REST microservice. This cookie likely contains authentication information (for instance, a JWT), enabling the microservice to identify the user making the request.
This authentication flow ensures secure communication between the Next.js server and the API REST backend.
The Homepage
The homepage doesn't use the tRPC stuff we just created. As a React Server Component (RSC), it directly communicates with our API REST backend using the callTodoService
function.
The homepage is responsible for retrieving data that doesn't require client-side interactivity, like our to-do list and priorities:
// inside src/app/page.tsx
import { auth } from "@/auth";
import { Todo } from "@repo/todos";
import AuthButton from "@/components/AuthButton.server";
import TodoList from "./TodoList";
import { callTodoService } from "@/todo-api";
import RQProvider from "@/tRPC/RQProvider";
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[])
: [];
return (
<RQProvider>
<main>
<AuthButton />
{session?.user && <TodoList todos={todos} priorities={priorities} />}
</main>
</RQProvider>
);
}
Notice how the homepage retrieves the list of priorities and to-dos using the callTodoService
function. This data is then passed to the TodoList
component for rendering.
The TodoList
Client Component
The TodoList
component handles client-side interactions within our app. For any actions that modify data, like adding a new to-do item, it utilizes the tRPC client to communicate with our Next.js server.
In the code, the TodoList
component utilizes tRPC's useQuery
to fetch and manage the list of to-dos. It also defines a mutation using useMutation
to handle adding new to-do items:
"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";
import { tRPC } from "@/tRPC/client";
export default function TodoList({
priorities,
todos: initialTodos,
}: {
priorities: string[];
todos: Todo[];
}) {
const [priority, setPriority] = useState<string>(priorities[0]);
const [title, setTitle] = useState<string>("");
const { data: todos, refetch: refetchTodos } = tRPC.getTodos.useQuery(
undefined,
{
initialData: initialTodos,
}
);
const addTodo = tRPC.addTodo.useMutation({
onSuccess: () => {
setTitle("");
refetchTodos();
},
});
const onSubmit = async () => {
await addTodo.mutate({
title,
priority,
completed: false,
});
};
const updateCompleted = tRPC.updateCompleted.useMutation({
onSuccess: () => refetchTodos(),
});
const onSetCompleted = async (id: string, completed: boolean) => {
await updateCompleted.mutate({
id,
completed,
});
refetchTodos();
};
const deleteTodo = tRPC.deleteTodo.useMutation({
onSuccess: () => refetchTodos(),
});
const onDelete = async (id: string) => {
await deleteTodo.mutate(id);
refetchTodos();
};
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 separation ensures a clear and maintainable codebase. The client focuses on presentation and user interaction, while the server (our Next.js BFF) handles data fetching, mutations, and communication with the microservice.
By combining tRPC with a Next.js BFF, we've established a type-safe communication flow between our frontend, backend, and microservices. This setup simplifies our frontend code by abstracting away data fetching and mutations while ensuring type safety across the entire stack.