Next.js ConnectRPC Proxy Pattern

Published: (February 4, 2026 at 09:05 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

Cover image for Next.js ConnectRPC Proxy Pattern

Azad Shukor

1. Dependencies

Required packages for ConnectRPC v2, Next.js 15, and TanStack Query.

package.json

{
  "name": "airborneo-grpc-proxy",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint",
    "generate": "buf generate proto"
  },
  "dependencies": {
    "@bufbuild/protobuf": "^2.2.3",
    "@connectrpc/connect": "^2.0.1",
    "@connectrpc/connect-next": "^2.0.1",
    "@connectrpc/connect-node": "^2.1.1",
    "@connectrpc/connect-query": "^2.0.1",
    "@connectrpc/connect-web": "^2.1.1",
    "@tanstack/react-query": "^5.0.0",
    "next": "15.1.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@bufbuild/buf": "^1.47.0",
    "@bufbuild/protoc-gen-es": "^2.2.3",
    "@connectrpc/protoc-gen-connect-query": "^2.0.1",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "typescript": "^5"
  }
}

2. Proto Definition & Generation

We define two proto files. GreetService imports ElizaService messages to ensure strict type parity between the proxy and the upstream backend.

proto/connectrpc/eliza/v1/eliza.proto

syntax = "proto3";

package connectrpc.eliza.v1;

message SayRequest {
  string sentence = 1;
}

message SayResponse {
  string sentence = 1;
}

service ElizaService {
  rpc Say(SayRequest) returns (SayResponse) {}
}

proto/greet/v1/greet.proto

syntax = "proto3";

package greet.v1;

import "connectrpc/eliza/v1/eliza.proto";

// This service mimics the remote service but is exposed locally
service GreetService {
  rpc Say(connectrpc.eliza.v1.SayRequest) returns (connectrpc.eliza.v1.SayResponse) {}
}

buf.gen.yaml

version: v1
plugins:
  # 1. Generates Types AND Service Definitions (v2 Standard)
  - plugin: es
    path: ./node_modules/.bin/protoc-gen-es
    out: gen
    opt: target=ts

  # 2. Generates React Hooks (TanStack Query)
  - plugin: connect-query
    path: ./node_modules/.bin/protoc-gen-connect-query
    out: gen
    opt: target=ts

Run the generator:

npm run generate

3. The Proxy Utility

This utility dynamically implements a service definition by forwarding method calls to a target client. It lets us “mount” the remote backend onto our local API route without manually rewriting every resolver.

src/utils/proxy.ts

import { type DescService } from "@bufbuild/protobuf";
import { type Client } from "@connectrpc/connect";

export function createProxy(
  service: T,
  client: Client,
) {
  const implementation: any = {};

  for (const method of service.methods) {
    const fnName = method.localName;

    implementation[fnName] = async (req: any) => {
      console.log(`[Proxy] Forwarding ${fnName} for ${service.typeName}`);

      // Call the client dynamically
      return await (client as any)[fnName](req);
    };
  }

  console.log(
    `[Proxy] Built methods for ${service.typeName}:`,
    Object.keys(implementation),
  );

  return implementation;
}

4. API Route Implementation

We create a backend connection to the demo Eliza service, then route our local GreetService to proxy that connection.

pages/api/[[...connect]].ts

import { nextJsApiRouter } from "@connectrpc/connect-next";
import { ConnectRouter } from "@connectrpc/connect";
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-node";

// Generated Code
import { GreetService } from "@/gen/greet/v1/greet_pb";
import { ElizaService } from "@/gen/connectrpc/eliza/v1/eliza_pb";
import { createProxy } from "@/src/utils/proxy";

// 1. The Backend Connection (Target)
const elizaBackend = createClient(
  ElizaService,
  createConnectTransport({
    baseUrl: "https://demo.connectrpc.com",
    httpVersion: "1.1",
  }),
);

function routes(router: ConnectRouter) {
  // ---------------------------------------------------------
  // ROUTE 1: GreetService (Proxy -> ElizaService)
  // URL: /api/greet.v1.GreetService/Say
  // ---------------------------------------------------------
  router.service(GreetService, createProxy(GreetService, elizaBackend));
}

export const { handler, config } = nextJsApiRouter({ routes });
export default handler;

5. Client Consumption

The frontend uses connect-web to talk to the local Next.js API. (The original article continues with example client code; include it here as needed.)

app/page.tsx

"use client";

import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { GreetService } from "@/gen/greet/v1/greet_pb";

// Point to YOUR local API
const transport = createConnectTransport({ baseUrl: "/api" });
const client = createClient(GreetService, transport);

export default function ChatPage() {
  const [input, setInput] = useState("");
  const [history, setHistory] = useState([]);

  const mutation = useMutation({
    mutationFn: async (msg: string) => {
      // Input is typed as SayRequest (from Eliza)
      const res = await client.say({ sentence: msg });

      // Output is typed as SayResponse (from Eliza)
      return res.sentence;
    },
    onSuccess: (answer) => {
      setHistory((prev) => [...prev, `Eliza (via Proxy): ${answer}`]);
    },
  });

  return (
    
      
## Proxy Chat

      
         setInput(e.target.value)}
          placeholder="Say something..."
          style={{ padding: "8px", flexGrow: 1 }}
        />
         mutation.mutate(input)}
          disabled={mutation.isPending}
        >
          {mutation.isPending ? "Sending..." : "Send"}
        
      

      
        {history.map((line, i) => (
          
            {line}
          
        ))}
      
    
  );
}
Back to Blog

Related posts

Read more »