Building a TypeScript gRPC Service with ConnectRPC + Buf Schema Registry
There are many ways to reliably share API contracts across microservice teams, but most rely on documentation synchronization or "more careful communication." ConnectRPC and the Buf Schema Registry (BSR) approach this problem structurally. A single .proto file simultaneously generates server handler types, client stubs, and browser hooks, while breaking changes are automatically blocked at the registry level.
This article covers step by step why ConnectRPC solves the browser compatibility problem, how BSR's automatic SDK generation changes cross-team collaboration, and how to connect everything from a Node.js server to a React client in practice.
Understanding the Core Concepts
The Browser Problem with gRPC and ConnectRPC's Solution
gRPC relies on HTTP/2 trailers, which browsers do not expose through the JavaScript API. This led to a workaround called gRPC-Web, but without servers that natively support it, a proxy like Envoy was practically mandatory.
With ConnectRPC, a single server simultaneously handles all three protocols — Connect, gRPC, and gRPC-Web — and browsers call the server directly using the standard fetch API. Buf's own BSR frontend, which removed Envoy and switched to Connect, is a prominent production example.
| Protocol | Direct Browser Support | HTTP/1.1 | JSON Support | Proxy Required |
|---|---|---|---|---|
| gRPC | ✗ | ✗ | ✗ | ✓ Required |
| gRPC-Web | ✓ | ✓ | △ | ✓ Required |
| Connect | ✓ | ✓ | ✓ | ✗ |
The default encoding for the Connect protocol is JSON. During development, you can read payloads directly in the browser DevTools Network tab, and you can also call endpoints directly with curl.
Buf Schema Registry — A Protobuf Registry You Use Like npm
The core of BSR is automatic SDK generation. When you push a .proto file to BSR, it immediately builds multi-language SDKs for TypeScript, Go, Python, and more. Importantly, BSR does not publish packages to npmjs.com — instead, it runs its own npm-compatible registry (npm.buf.build). Team B needs to configure .npmrc before running npm install @buf/....
Since late 2024, BSR Governance Workflow has also been added. It automatically blocks pushes that contain breaking changes and provides a review flow where code owners can review, approve, or reject them.
Distinguishing the Two Config Files: buf.yaml and buf.gen.yaml
If you're new to this, the two files can be confusing. Their roles are completely different.
buf.yaml: Defines the Protobuf module itself. Declares the module name, dependencies, lint rules, and breaking change detection level. This file is the reference when pushing to BSR.buf.gen.yaml: Controls code generation. Specifies which plugins to use and where to output files. This file is read when runningbuf generate.
An easy way to remember: buf.yaml is "what is this module," and buf.gen.yaml is "what do we generate from this module."
Packages Used for Code Generation
| Package | Role |
|---|---|
@bufbuild/protobuf |
Protobuf message runtime |
@bufbuild/protoc-gen-es |
TypeScript type code generation plugin |
@connectrpc/connect |
Server/client core |
@connectrpc/protoc-gen-connect-es |
Service stub code generation plugin |
@connectrpc/connect-web |
Browser transport layer |
@connectrpc/connect-query |
TanStack Query integration hooks |
Code generation is controlled by a single buf.gen.yaml.
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/bufbuild/es
out: src/gen
- remote: buf.build/connectrpc/es
out: src/gen
inputs:
- module: buf.build/myorg/paymentsRunning buf generate creates *_pb.ts (message types) and *_connect.ts (service interfaces) under src/gen/.
Practical Setup Steps
Prerequisites: Installing the buf CLI
# macOS
brew install bufbuild/buf/buf
# or the official install script
curl -sSL https://github.com/bufbuild/buf/releases/latest/download/buf-$(uname -s)-$(uname -m) -o buf
chmod +x buf && mv buf /usr/local/bin/After installation, verify it works with buf --version.
Step 1: Project Structure and Writing the .proto File
Start by setting up the overall directory structure.
my-payments-app/
├── proto/
│ └── payments/
│ └── v1/
│ └── payments.proto
├── src/
│ ├── gen/ # buf generate output (can be gitignored)
│ │ └── payments/
│ │ └── v1/
│ │ ├── payments_pb.ts
│ │ └── payments_connect.ts
│ ├── server.ts
│ └── client/
│ ├── providers.tsx
│ ├── App.tsx
│ └── PaymentForm.tsx
├── buf.yaml # module definition
├── buf.gen.yaml # code generation config
└── buf.lockWrite the service definition.
// proto/payments/v1/payments.proto
syntax = "proto3";
package payments.v1;
message CreatePaymentRequest {
string user_id = 1;
int64 amount_cents = 2;
string currency = 3;
}
message CreatePaymentResponse {
string payment_id = 1;
string status = 2;
}
service PaymentsService {
rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse);
}The buf.yaml that defines the module.
# buf.yaml
version: v2
name: buf.build/myorg/payments
lint:
use:
- DEFAULT
breaking:
use:
- FILEbreaking: use: - FILE checks compatibility at the file level. It detects changes that could break existing clients — such as removing fields, changing types, or deleting RPC methods. It is stricter than WIRE (which only checks serialization compatibility) and is the level recommended for most teams. This setting is tied to BSR's push-blocking criteria.
buf.lock serves the same role as package-lock.json, so it must be committed to git.
Step 2: Verifying Local Code Generation
You can verify code generation locally first, even without a BSR account.
buf generatepayments_pb.ts and payments_connect.ts are generated under src/gen/payments/v1/. Whether to commit the generated files to git or add them to .gitignore and generate them in CI is up to team agreement. Either way, buf.yaml and buf.gen.yaml must always be version-controlled.
Step 3: Implementing the Node.js + Express Server
// src/server.ts
import express from "express";
import { expressConnectMiddleware } from "@connectrpc/connect-express";
import { PaymentsService } from "./gen/payments/v1/payments_connect";
import type { ConnectRouter } from "@connectrpc/connect";
function routes(router: ConnectRouter) {
router.service(PaymentsService, {
async createPayment(req) {
// placeholder for actual payment processing logic
const paymentId = `pay_${crypto.randomUUID()}`;
return {
paymentId,
status: "succeeded",
};
},
});
}
const app = express();
app.use(expressConnectMiddleware({ routes }));
app.listen(3000, () => console.log("Server running: http://localhost:3000"));TypeScript automatically infers req.userId, req.amountCents, and req.currency. Proto field names are converted to camelCase, and accessing wrong fields or returning mismatched types produces compile errors.
Step 4: Pushing to BSR and Installing the npm SDK
Once the server implementation is verified, push the schema to BSR.
buf pushWhen the push succeeds, BSR automatically builds the TypeScript SDK. This SDK is served from BSR's own npm-compatible registry (npm.buf.build). Consuming team projects need to add .npmrc.
# .npmrc
@buf:registry=https://npm.buf.buildTeam B can now use a type-safe client directly, without running buf generate.
# How Team B uses Team A's payment service client
pnpm add @buf/myorg_payments.connectrpc_esThe full cross-team collaboration flow looks like this.
If Team A removes a field from CreatePaymentRequest or changes a type, BSR blocks the push and starts the review flow. This is structural defense at the registry level, not "more careful communication."
Step 5: React Client + TanStack Query Integration
pnpm add @buf/myorg_payments.connectrpc_es @connectrpc/connect-query @connectrpc/connect-web @tanstack/react-query// src/client/providers.tsx
import { createConnectTransport } from "@connectrpc/connect-web";
import { TransportProvider } from "@connectrpc/connect-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const transport = createConnectTransport({
baseUrl: "http://localhost:3000",
});
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<TransportProvider transport={transport}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</TransportProvider>
);
}Wrap the top level of your app with Providers.
// src/client/App.tsx (for Next.js, use app/layout.tsx)
import { Providers } from "./providers";
import { PaymentForm } from "./PaymentForm";
export default function App() {
return (
<Providers>
<PaymentForm />
</Providers>
);
}// src/client/PaymentForm.tsx
import { useMutation } from "@connectrpc/connect-query";
// After installing the BSR npm SDK, check the actual path in the generated files inside the package.
// The path format may differ depending on the connect-query plugin version.
import { createPayment } from "@buf/myorg_payments.connectrpc_es/payments/v1/payments_connectquery";
export function PaymentForm() {
const { mutate, data, isPending, isError, error } = useMutation(createPayment);
const handleSubmit = () => {
mutate({
userId: "user_123",
amountCents: BigInt(9900),
currency: "KRW",
});
};
return (
<div>
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? "Processing..." : "Pay"}
</button>
{isError && <p style={{ color: "red" }}>Error: {error.message}</p>}
{data && <p>Payment complete: {data.paymentId}</p>}
</div>
);
}No query key management or cache invalidation logic is needed. useMutation automatically infers types and query keys from the service method, and errors are delivered in a type-safe manner.
Pros and Cons
Pros
| Item | Actual Effect |
|---|---|
| Direct Browser Compatibility | Call gRPC servers directly from the browser via fetch, without Envoy/nginx proxies |
| Single Source of Truth for Types | One .proto synchronizes all server, client, and browser types |
| Multi-Protocol Single Server | The same handler simultaneously handles Connect, gRPC, and gRPC-Web requests |
| curl/DevTools Debugging | Default JSON encoding lets you inspect payloads directly in the Network tab |
| gRPC Backward Compatibility | Connect clients can communicate with gRPC servers; reuse existing infrastructure |
| BSR Automatic npm SDK | npm package generated immediately on push; no codegen required for consuming teams |
| Automatic Breaking Change Protection | Registry-level blocking of breaking pushes before they land |
Considerations
| Item | Practical Impact |
|---|---|
| Browser Client Streaming Limitations | Client streaming has limited support; server streaming works normally |
| No Native gRPC in Express | No HTTP/2 support; can only serve Connect + gRPC-Web |
| Slow First Install of BSR npm SDK | Lazy generation may cause slowness on the initial install |
| No Native NestJS Support | NestJS transporters don't directly support ConnectRPC; separate integration required |
| Learning Curve | Must learn the full Protobuf IDL + buf CLI + BSR + connect-es stack |
| Ecosystem Maturity | Fewer third-party middlewares and plugins compared to gRPC |
Four Common Mistakes
1. Trying to Connect a gRPC Client to Express
@connectrpc/connect-express does not support HTTP/2. If you need to communicate with standard gRPC clients in Go or Python, choose node:http2 servers or Fastify instead of Express.
2. Installing @buf/... Packages Without .npmrc
BSR serves packages from npm.buf.build, not npmjs.com. Without @buf:registry=https://npm.buf.build in .npmrc, npm install returns 404. Configure this in your project root .npmrc or ~/.npmrc.
3. Operating Without buf.lock
buf.lock serves the same role as package-lock.json. Not committing this file to git can cause dependency divergence between CI and local environments.
4. Using Streaming with Short HTTP Timeouts
The HTTP server's ReadTimeout and WriteTimeout apply to the full duration of a streaming RPC. If you use streaming methods, consider increasing the timeout values significantly, or separating a dedicated streaming server.
Closing
A single .proto file automatically generates server handler types, client stubs, browser hooks, and a shared SDK across teams. Browsers can call gRPC servers directly without a proxy, and breaking changes are automatically blocked at the registry level.
If you want to get started now, try this approach in order.
- Start with installing buf CLI and running local
buf generate— Even without a BSR account, you can experience TypeScript code generation first usingbuf.gen.yaml+ local plugins. - Implement a single service with Express + connect-express — You can add a ConnectRPC endpoint to an existing Express app with just one line of middleware.
- Push to BSR and consume via npm SDK — Push to BSR with
buf push, then configure.npmrcin another project and install a type-safe client withpnpm add @buf/...to feel how cross-team collaboration changes.
References
- ConnectRPC Official Documentation
- connect-es GitHub (TypeScript implementation)
- protobuf-es GitHub (Protobuf TypeScript runtime)
- Buf Schema Registry Official Documentation
- BSR Generated SDKs — npm Usage Guide
- buf.gen.yaml v2 Configuration Reference
- Buf Breaking Change Detection Documentation
- connect-query-es GitHub (TanStack Query integration)
- Buf Blog: Connect — A Better gRPC
- Buf Blog: Building Node.js + TypeScript gRPC Microservices
- Buf Blog: BSR Governance Workflow
- BSR Generated SDKs: Give clients pre-built native libraries