Language: English
The Minimal-Cost Way to Start a BFF: tRPC
Choosing tRPC over GraphQL, gRPC, and ConnectRPC for a high-churn BFF layer, plus the reasoning behind keeping types in TypeScript rather than extracting them into a separate shared schema layer.
The BFF belongs to the frontend, so its change frequency matches the frontend’s. When the UI changes often, the BFF follows along just as often. So I want to judge technology choices by whether they can survive that change frequency.
Why the BFF is that kind of layer is covered in the previous post. Here I’ll talk about implementation options.
The BFF and API Shape
A BFF is a layer tightly coupled to client use cases. Its shape is: return, in one response, the data a single screen needs.
Given that, purpose-built — in other words, RPC-style — API design fits more naturally than resource-oriented design.
Rather than lining up GET /tasks, GET /users, and GET /labels, a getAnnotationScreenData-shaped endpoint is more straightforward.
Comparing GraphQL / gRPC / ConnectRPC / tRPC
| GraphQL | gRPC | ConnectRPC | tRPC | |
|---|---|---|---|---|
| Schema language | SDL | Protobuf | Protobuf | TypeScript |
| Type generation | Required (codegen) | Required (protoc) | Required (protoc) | Not needed |
| Non-TS clients | Yes | Yes | Yes | No |
| Browser friendliness | High | Requires HTTP/2 | High | High |
| Learning cost | High | Medium | Medium | Low |
| Initial setup | Heavy | Medium–Heavy | Medium | Light |
With GraphQL you can carve Queries and Mutations per use case. gRPC speaks Protobuf over HTTP/2; ConnectRPC is Protobuf-based but can also speak HTTP/1.1 and JSON.
All three can serve clients written in other languages. The trade-off is that you maintain a separate schema language and a code generation pipeline. That upkeep is quietly annoying. Version drift between libraries leaving generated code full of type errors happens all the time.
tRPC can’t serve non-TS clients. In exchange, TypeScript types are the schema itself, so code generation and initial setup shrink to a minimum.
The State of Hand-Written Shared Types
For comparison, let’s look at a setup that attaches hand-written types to REST.
// server (Express)
app.get('/tasks', async (req, res) => {
res.json(await findTasks(req.query.status));
});
// client: 型をどう共有する?
type Task = { id: string; title: string; status: 'todo' | 'done' };
const res = await fetch(`/tasks?status=${status}`);
const tasks: Task[] = await res.json(); // 信用するしかない
Types get defined twice, once on the server and once on the client. Input validation and response checking each have to be implemented separately.
And even when the schema drifts, compilation still passes.
await res.json() is any, so the type annotation you write there is pure declaration.
It has no relation to what actually comes back.
tRPC Router Definitions
On the server you define procedures and export their types.
// server: ルーター定義
export const appRouter = router({
task: router({
list: publicProcedure
.input(z.object({ status: z.enum(['todo','done']) }))
.query(({ input }) => db.task.findMany({ where: input })),
}),
});
export type AppRouter = typeof appRouter;
The client just imports, and both completion and type checking work.
// client
const tasks = await trpc.task.list.query({ status: 'todo' });
// ^^^^^^
// 'todo' | 'done' に絞られている
Zod handles input validation. Return types arrive just by importing. Schema drift breaks compilation, so you catch it during development.
What Goes Away
Using tRPC makes a whole bundle of things unnecessary:
- Schema files (OpenAPI / GraphQL SDL)
- Code generation pipelines (orval / graphql-codegen)
- CI drift checks (comparing generated artifacts against the implementation)
- Runtime response validation code
Almost nothing about type syncing remains to decide. Each change involves fewer steps, which pairs well with a high-churn layer.
The Decision Not to Split Out Types
The value of extracting types into their own layer lies in sharing and reuse. Let’s look at where that value actually arises.
Between the backend and the BFF, you handle data returned by multiple services. So shared types are needed to keep things consistent. Externalizing them as schema files would also let multiple BFFs reuse them.
Between the BFF and the client it’s one-to-one. A BFF stands up per client, so extracting types into a separate layer for reuse buys little.
Still, you want the frontend written type-safely. No sharing needed — just type safety. Those conditions match a mechanism where types arrive simply by importing.
When It Doesn’t Fit
Of course, there are situations where it doesn’t fit.
| Situation | tRPC |
|---|---|
| Non-TS client required | No |
| Want a public API | No |
| Want to hit it with curl or Postman | Cumbersome |
| Server isn’t TS | No |
If any of these apply, REST or GraphQL is the better fit.
The Escape Hatch If You Need It Later
A non-TS client might become necessary later.
In principle the BFF and client should stay one-to-one, but there are cases where shared screen-level logic has to live somewhere. In that case, you might decide that maintaining two BFFs doesn’t justify its maintenance cost.
tRPC procedures carry Zod schemas.
You can project them to OpenAPI with something like trpc-to-openapi, and generating proto from there isn’t hard either.
iOS and Android can migrate by generating types from the projected OpenAPI.
Build a code generation pipeline up front for every language, or project when the need arises? Given a BFF’s change frequency, starting with the latter is usually cheaper.
Based on the second half of my LT talk, Introducing a BFF for E2E Type Safety with Less Effort, given at the TSKaigi 2026 after-party “TSKaigi2026しか型ん” (a pun: shikatan “can’t be helped”, with 型 “type”).