---
title: "gRPC×Connect-ESをRemixで使う実装｜React Router v8のloaderとRemix 3から呼ぶ【2.3.0実測】"
url: "https://www.issoh.co.jp/tech/details/7063/"
published: 2025-06-02
updated: 2026-11-05
categories: ["TypeScript"]
publisher: "株式会社一創"
---

# gRPC×Connect-ESをRemixで使う実装｜React Router v8のloaderとRemix 3から呼ぶ【2.3.0実測】

RemixからgRPCのサービスを呼ぶなら、組みやすいのはConnect-ES（TypeScript版のConnect）です。Connectのサーバーは1つの実装でConnect・gRPC・gRPC-Webの3プロトコルを受け付けるので、サーバー側からもブラウザからも同じprotoの型で呼べます。

2026年11月時点で「Remix」は2系統あります。Remix v2の後継であるReact Router v8と、2026年10月に正式版が出たRemix 3です。本記事では、Connect-ES 2.3.0でのコード生成・サーバー・認証と、両方のRemixからの呼び出しを、実際に動かしたコードと出力で示します。

## まとめ：Connect-ESでRemixからgRPC互換APIを呼ぶ要点

- コード生成は`@bufbuild/protoc-gen-es`の1本で足ります。v2以降はサービス定義も`*_pb.ts`に出力され、protoc-gen-connect-esは使いません。
- サーバーは`connectNodeAdapter`で立てます。HTTP/1.1の`node:http`ではgRPCクライアントだけがつながりません（実測で`Protocol error`）。gRPCの呼び出し元があるならHTTP/2で待ち受けます。
- 認証はinterceptorでなく`requestGate`で行い、`readMaxBytes`で受け付けるメッセージの大きさを制限します。どちらも指定しないと、未認証のリクエストも任意の大きさの本文も読み込まれます。
- React Router v8ではloaderから、Remix 3ではルートハンドラーから、サーバー側のConnectクライアントを呼ぶ構成を基本にします。CORSが不要で、APIの接続先をブラウザに出しません。
- 入力に合わせた再取得やキャッシュが要る画面だけ、ブラウザから`@connectrpc/connect-query`で直接呼びます。

## Remix×Connectの構成と2026年11月時点の前提

gRPCはHTTP/2のトレーラーに依存するため、ブラウザのfetchからは素のgRPCを呼べません。Connectは、同じサーバーがConnect・gRPC・gRPC-Webの3種類を話すことでこの制約を避けます。gRPCそのものの仕組みは[gRPCの仕組みとTCP・HTTP/3での実測](/tech/details/3404/)、Connectの位置づけとGo実装は[Connect（connectrpc）とconnect-go v2の解説](/tech/details/5504/)にまとめています。

### サーバーの待ち受け方式別のプロトコル到達性

Connect-Nodeのサーバーを3つの方式で立て、Node.jsのクライアントから各Transportで接続した結果です。ブラウザの列だけは[Connect-Nodeの公式ドキュメント](https://connectrpc.com/docs/node/server-plugins/)の記述によります。

| 待ち受け方式                    | Connect | gRPC-Web | gRPC           | ブラウザ |
| ------------------------- | ------- | -------- | -------------- | ---- |
| node:http（HTTP/1.1）       | 成功      | 成功       | Protocol error | 接続可  |
| node:http2 平文（h2c）        | 成功      | 成功       | 成功             | 接続不可 |
| node:http2 TLS＋allowHTTP1 | 成功      | 成功       | 成功             | 接続可  |

公式ドキュメントは、HTTP/1.1ではgRPCと双方向ストリーミングを提供できず、ブラウザは平文のHTTP/2を拒否すると明記しています。本記事のunary RPCをConnectまたはgRPC-Webで呼ぶ構成なら、HTTP/1.1で接続できます。loaderからでも、gRPCや双方向ストリーミングを使う場合はHTTP/2が必要です。

### 「Remix」が指すReact Router v8とRemix 3

Remixチームは2024年5月15日、Remix v3として予定していた機能を[React Router v7として出すと発表](https://remix.run/blog/merging-remix-and-react-router)し、Remix v2のアプリはReact Routerへ移りました。現行はReact Router v8で、npmの公開日時は8.0.0が2026年6月17日（UTC）です。検証に使った8.5.1は、package.jsonでNode.js 22.22.0以上を要求します。loaderと`Route`型を使う書き方はこちらの系統です。

Remix 3は[npmのremixパッケージ](https://www.npmjs.com/package/remix)として配布され、公開日時は3.0.0が2026年10月1日、3.2.0が11月4日です（いずれもUTC）。Reactに依存しない独自のコンポーネントと、fetch APIの`Request`を受けて`Response`を返すルーターで組む別系統で、Node.js 24.3.0以上を要求します。Remix 3のコンポーネントの書き方は[Remix 3のコンポーネントの解説](/tech/details/10504/)、Remix v2からReact Routerへの移行は[Remix SPAモードとReact Router v8への移行の解説](/tech/details/3840/)で扱っています。

### 検証に使ったバージョン

本記事のコードと出力は、2026年11月5日にNode.js 26.5.0で実行したものです。

| パッケージ                                        | バージョン   |
| -------------------------------------------- | ------- |
| @connectrpc/connect／connect-node／connect-web | 2.3.0   |
| @bufbuild/protobuf／protoc-gen-es             | 2.16.0  |
| @bufbuild/buf                                | 1.75.0  |
| react-router／@react-router/dev               | 8.5.1   |
| remix                                        | 3.2.0   |
| @connectrpc/connect-query                    | 2.3.1   |
| @tanstack/react-query                        | 5.106.0 |

`@connectrpc/connect-node`はpackage.jsonでNode.js 22以上を要求します。Node.js 20のサポートは2.2.0で打ち切られました。

## Connect-ES v2系のコード生成手順（protoc-gen-connect-es不要）

### Connect-ESの実行依存とコード生成用パッケージ

実行時のライブラリと、コード生成用の開発依存を分けて入れます。

```
npm install @connectrpc/connect@2.3.0 @connectrpc/connect-node@2.3.0 @connectrpc/connect-web@2.3.0 @bufbuild/protobuf@2.16.0
npm install -D @bufbuild/buf@1.75.0 @bufbuild/protoc-gen-es@2.16.0 tsx@4.23.15
```

### protoとbuf.gen.yamlの書き方

名前を受け取って挨拶を返す1メソッドのサービスを定義します。`idempotency_level`は後述するGETリクエストのための指定です。

```
// proto/greet/v1/greet.proto
syntax = "proto3";

package greet.v1;

message GreetRequest {
  string name = 1;
}

message GreetResponse {
  string greeting = 1;
}

service GreetService {
  rpc Greet(GreetRequest) returns (GreetResponse) {
    option idempotency_level = NO_SIDE_EFFECTS;
  }
}
```

buf.yamlでモジュールとLintの規則を、buf.gen.yamlで生成プラグインを指定します。プラグインは`protoc-gen-es`の1つだけです。

```
# buf.yaml
version: v2
modules:
  - path: proto
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
```

```
# buf.gen.yaml
version: v2
inputs:
  - directory: proto
plugins:
  - local: protoc-gen-es
    out: src/gen
    opt: target=ts
```

```
npx buf lint
npx buf generate
```

生成されるのは`src/gen/greet/v1/greet_pb.ts`の1ファイルで、メッセージのスキーマと`GreetService`のサービス定義が同じファイルに入ります。v1時代の`*_connect.ts`は出力されません。

### v1からの移行で変わる点

Connect-ES 2.0.0は2024年11月19日（UTC）にGitHubで公開されました。[公式の移行ガイド](https://github.com/connectrpc/connect-es/blob/main/MIGRATING.md)は、protoc-gen-connect-esをpackage.jsonとbuf.gen.yamlの両方から削除するよう求めています。npmには`@connectrpc/protoc-gen-connect-es` 1.7.0が残っており、npmのページだけを見ると現役に見えますが、v2のランタイムとは組み合わせません。

- 依存とbuf.gen.yamlの書き換えは`npx @connectrpc/connect-migrate@latest`が自動で行います。
- import先は`*_connect`から`*_pb`に変わります。
- メッセージはES6クラスでなくプレーンなオブジェクトになり、loaderの戻り値としてそのままシリアライズできます。

## Connect-Nodeサーバーの実装とcurlでの動作確認

### サービス実装とconnectNodeAdapterの起動

サービスの実装は`connect.ts`に分けておくと、後述のHTTP/2版やRemix 3との同居でも同じものを使い回せます。入力が不正なときは`ConnectError`にコードを付けて投げます。

```
// src/connect.ts
import type { ConnectRouter } from "@connectrpc/connect";
import { ConnectError, Code } from "@connectrpc/connect";
import { GreetService } from "./gen/greet/v1/greet_pb";

export default (router: ConnectRouter) =>
  router.service(GreetService, {
    async greet(req) {
      if (req.name === "") {
        throw new ConnectError("name is required", Code.InvalidArgument);
      }
      return { greeting: `Hello, ${req.name}` };
    },
  });
```

Node.js標準の`node:http`に`connectNodeAdapter`を渡すと、HTTP/1.1でConnectとgRPC-Webを受け付けるサーバーになります。

```
// src/server.ts
import { createServer } from "node:http";
import { connectNodeAdapter } from "@connectrpc/connect-node";
import { ConnectError, Code, type HandlerContext } from "@connectrpc/connect";
import routes from "./connect";

const apiToken = process.env.API_TOKEN;
if (!apiToken) {
  throw new Error("API_TOKEN must be set");
}

function authGate(context: HandlerContext) {
  if (context.requestHeader.get("Authorization") !== `Bearer ${apiToken}`) {
    throw new ConnectError("unauthenticated", Code.Unauthenticated);
  }
}

createServer(
  connectNodeAdapter({
    routes,
    requestGate: authGate,
    readMaxBytes: 1024 * 1024, // 1メッセージ1MiBまで
  }),
).listen(8080);
```

### requestGateによる認証とreadMaxBytesの上限

上の`server.ts`は、`Authorization`ヘッダーのトークンが一致しないリクエストを`requestGate`で断ります。`API_TOKEN`が未設定のまま起動すると比較相手が`Bearer undefined`になり、その文字列を送れば通ってしまうため、起動時に止めています。Connect-ESの[interceptorのドキュメント](https://connectrpc.com/docs/node/interceptors/)は、サーバーの認証にinterceptorを使わないよう求めています。unaryのRPCでは、interceptorはリクエスト本文を受信・展開・パースした後に動くためです。`requestGate`はヘッダーが届いた時点で動き、`ConnectError`を投げると本文を読まずにRPCを終えます。

[リリースノート](https://github.com/connectrpc/connect-es/releases)によると、`requestGate`は2026年9月7日の2.2.0で追加されました。同じリリースでメッセージサイズの上限設定も推奨されています。サーバーは既定で任意の大きさのメッセージを受け付けるため、外部に公開するなら`readMaxBytes`を指定します。

| リクエスト             | 結果のコード                    |
| ----------------- | ------------------------- |
| トークンなし            | unauthenticated（HTTP 401） |
| 2MiBのname（上限1MiB） | resource\_exhausted       |
| 正しいトークン・通常のname   | 成功（HTTP 200）              |

2MiBの例では、エラーメッセージに`message size is larger than configured readMaxBytes 1048576`が返りました。ゲートで決めたユーザー情報をサービス実装へ渡すには、公式ドキュメントにあるcontext valuesを使います。

### curlによるPOST・GET・エラーレスポンスの確認

APIプロジェクトのルートで`API_TOKEN=secret-token npx tsx src/server.ts`を実行してサーバーを起動し、別のターミナルで同じ`API_TOKEN`を設定して以下のcurlを実行します。コメントはレスポンス例で、ステータス行も見るなら`-i`を付けます。パスは`/パッケージ名.サービス名/メソッド名`です。

```
curl -X POST http://localhost:8080/greet.v1.GreetService/Greet \
  -H "Content-Type: application/json" -d '{"name":"Remix"}'
# HTTP/1.1 401 Unauthorized
# {"code":"unauthenticated","message":"unauthenticated"}

curl -X POST http://localhost:8080/greet.v1.GreetService/Greet \
  -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" \
  -d '{"name":"Remix"}'
# HTTP/1.1 200 OK
# {"greeting":"Hello, Remix"}

curl -X POST http://localhost:8080/greet.v1.GreetService/Greet \
  -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" -d '{}'
# HTTP/1.1 400 Bad Request
# {"code":"invalid_argument","message":"name is required"}
```

`NO_SIDE_EFFECTS`を付けたメソッドはGETでも呼べます。リクエストはクエリ文字列に入るため、CDNやブラウザのHTTPキャッシュに載せられます。ただしGETにするだけでは保存されず、サーバーでCache-Controlなどの応答ヘッダーを設定する必要があります。ConnectクライアントからGETを使う場合は、Transportの`useHttpGet`も有効にします。

```
curl -H "Authorization: Bearer $API_TOKEN" \
  'http://localhost:8080/greet.v1.GreetService/Greet?connect=v1&encoding=json&message=%7B%22name%22%3A%22Remix%22%7D'
# HTTP/1.1 200 OK
# {"greeting":"Hello, Remix"}
```

同じprotoから`idempotency_level`だけを外して生成し直すと、同じGETは`405 Method Not Allowed`になり、POSTは200のままでした。

### gRPCクライアントも受けるHTTP/2の待ち受け構成

Goのサービスなど、素のgRPCで呼んでくる相手がいる場合は`node:http2`で立てます。HTTP/1.1のサーバーに`createGrpcTransport`で接続すると`[internal] Protocol error`で失敗し、次のHTTP/2サーバーでは同じ呼び出しが成功しました。

```
// src/server_h2.ts
import { createServer } from "node:http2";
import { connectNodeAdapter } from "@connectrpc/connect-node";
import routes from "./connect";

createServer(connectNodeAdapter({ routes })).listen(8081);
```

これは平文のHTTP/2（h2c）で、ブラウザはつながりません。ブラウザとgRPCクライアントを同じポートで受けるなら、TLSを付けて`allowHTTP1`を有効にします。検証ではこの1ポートで、gRPCとHTTP/1.1のConnect・gRPC-Webがすべて成功しました。例では`localhost`用の自己署名証明書と鍵をopensslで作って`certs/`に置き、Node.jsのクライアントにはTransportの`nodeOptions`の`ca`でその証明書を渡しています。ブラウザからも使うなら、公式のGetting startedが案内するmkcertで、ローカルで信頼される証明書を作ります。

```
// src/server_tls.ts
import { readFileSync } from "node:fs";
import { createSecureServer } from "node:http2";
import { connectNodeAdapter } from "@connectrpc/connect-node";
import routes from "./connect";

createSecureServer(
  {
    key: readFileSync("certs/key.pem"),
    cert: readFileSync("certs/cert.pem"),
    allowHTTP1: true, // HTTP/1.1しか話さないクライアントも同じポートで受ける
  },
  connectNodeAdapter({ routes }),
).listen(8443);
```

Node.js側のgRPCクライアントには、2.3.0（2026年10月27日）で接続確立のタイムアウト`connectTimeoutMs`が加わりました。応答しないアドレスに向けると、指定した時間で`unavailable`のエラーになります。

```
const transport = createGrpcTransport({
  baseUrl: "http://10.255.255.1:8081",
  connectTimeoutMs: 1000,
});
// 1秒でConnectError [unavailable] connection establishment timed out
```

## React Router v8のloaderからConnectクライアントを呼ぶ実装

SSRを有効にしたReact Router v8のFramework Modeプロジェクトを前提にします。先ほど生成した`src/gen`を、このプロジェクトの`app/gen`へコピーしてください。ルートは`app/routes.ts`に登録します。

```
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("client-greet", "routes/client-greet.tsx"),
] satisfies RouteConfig;
```

### サーバー専用のクライアントモジュール

Transportはリクエストごとに作らず、モジュールで1つ作って使い回します。サーバー側では`@connectrpc/connect-node`のTransportを使い、`httpVersion`を明示します。

```
// app/lib/greet.server.ts
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-node";
import { GreetService } from "../gen/greet/v1/greet_pb";

const transport = createConnectTransport({
  baseUrl: process.env.GREET_API_URL ?? "http://localhost:8080",
  httpVersion: "1.1",
});

export const greetClient = createClient(GreetService, transport);
```

ファイル名を`.server.ts`にしておくと、誤ってブラウザ側から使ったときにビルドが止まります。ボタンのクリック処理から`greetClient`を呼ぶルートを足すと、`react-router build`は`Server-only module referenced by client`で失敗しました。loaderからだけ使う構成では、クライアント向けの成果物に接続先の文字列は残りませんでした。

### loaderでの呼び出しとエラーのHTTPステータス変換

loaderは受け取った`request.signal`をRPCに渡します。ユーザーが画面遷移で読み込みを中断すると、バックエンドへの呼び出しも止まります。トークンはサーバーの環境変数から付けるので、ブラウザには出ません。

```
// app/routes/home.tsx
import { ConnectError, Code } from "@connectrpc/connect";
import { data } from "react-router";
import type { Route } from "./+types/home";
import { greetClient } from "../lib/greet.server";

export async function loader({ request }: Route.LoaderArgs) {
  const name = new URL(request.url).searchParams.get("name") ?? "";
  try {
    const res = await greetClient.greet(
      { name },
      {
        signal: request.signal,
        headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
      },
    );
    return { greeting: res.greeting };
  } catch (err) {
    const e = ConnectError.from(err);
    if (e.code === Code.InvalidArgument) {
      throw data(e.rawMessage, { status: 400 });
    }
    throw e;
  }
}

export default function Home({ loaderData }: Route.ComponentProps) {
  return <p>{loaderData.greeting}</p>;
}
```

`react-router typegen`で型を生成し、ビルドして`react-router-serve`で起動した結果、`/?name=Remix`は200で`Hello, Remix`を表示し、`name`なしは400を返しました。

変換先のHTTPステータスは、Connectプロトコルの対応表に合わせると画面側の分岐が揃います。loaderで扱うことの多いコードは次のとおりです。全16種は[gRPCステータスコードの対応表](/tech/details/17200/)を参照してください。

| Connectのコード         | HTTPステータス |
| ------------------- | --------- |
| invalid\_argument   | 400       |
| unauthenticated     | 401       |
| permission\_denied  | 403       |
| not\_found          | 404       |
| resource\_exhausted | 429       |
| unavailable         | 503       |
| deadline\_exceeded  | 504       |

## Remix 3のルートハンドラーからConnectを呼ぶ実装

Remix 3にはloaderがありません。ルーターに登録したハンドラーが`Request`を受け取り、`Response`を返します。Connectのクライアントはハンドラーの中で呼べばよく、React Router v8と同じ`connect-node`のTransportがそのまま使えます。

### remix/routerのハンドラーからのConnect呼び出し

次のファイルは、ハンドラーからの呼び出しと、後述する同一ポートでの配信を1つにまとめたものです。`npm install remix@3.2.0`を追加し、`API_TOKEN=secret-token npx tsx src/remix3.ts`で起動します。

```
// src/remix3.ts
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createClient, ConnectError, Code } from "@connectrpc/connect";
import { createConnectTransport, connectNodeAdapter } from "@connectrpc/connect-node";
import { createRequestListener } from "remix/node-fetch-server";
import { createRouter } from "remix/router";
import { route } from "remix/routes";
import { html } from "remix/html-template";
import { createHtmlResponse } from "remix/response/html";
import { GreetService } from "./gen/greet/v1/greet_pb";
import connectRoutes from "./connect";

const greetClient = createClient(
  GreetService,
  createConnectTransport({
    baseUrl: process.env.GREET_API_URL ?? "http://localhost:8080",
    httpVersion: "1.1",
  }),
);

const routes = route({ home: "/" });
const router = createRouter();

router.get(routes.home, async ({ request, url }) => {
  const name = url.searchParams.get("name") ?? "";
  try {
    const res = await greetClient.greet(
      { name },
      {
        signal: request.signal,
        headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
      },
    );
    return createHtmlResponse(html`<p>${res.greeting}</p>`);
  } catch (err) {
    const e = ConnectError.from(err);
    const status = e.code === Code.InvalidArgument ? 400 : 502;
    return createHtmlResponse(html`<p>${e.rawMessage}</p>`, { status });
  }
});

const remixListener = createRequestListener((request) => router.fetch(request));

// RPCのパスはConnectが処理し、それ以外はRemix 3のルーターへ渡す
// node:httpで立てるので、fallbackに来るのはHTTP/1.1のリクエストだけ
createServer(
  connectNodeAdapter({
    routes: connectRoutes,
    fallback: (req, res) =>
      remixListener(req as IncomingMessage, res as ServerResponse),
  }),
).listen(3000);
```

ハンドラーは`request`とパース済みの`url`を受け取り、`request.signal`を渡す点はloaderと同じです。HTMLは`remix/html-template`の`html`タグで組み立てます。埋め込んだ値は自動でエスケープされ、`name`に`<b>x</b>`を渡すと`Hello, &lt;b&gt;x&lt;/b&gt;`として返りました。この例で利用者の入力を含むRPCの結果をエスケープせずHTMLへ埋め込むと、反射型XSSを招く可能性があります。

検証では、`/?name=Remix`が200で`Hello, Remix`を、`name`なしが400を返しました。

### Connectのサーバーと同じポートで配信する構成

`connectNodeAdapter`の`fallback`オプションは、RPCのパスに一致しなかったリクエストの行き先を決めます。ここに`remix/node-fetch-server`の`createRequestListener`でRemix 3のルーターを渡すと、1つのポートで画面とRPCの両方を配信できます。上の例では、`/greet.v1.GreetService/Greet`へのPOSTはConnectが200で応答し、`/`はRemix 3の画面を、登録していないパスは404を返しました。

例のハンドラーは、別サービスとして動くAPIを呼ぶ想定で接続先を`GREET_API_URL`から読みます。この構成ではブラウザから同一オリジンでRPCを呼べるため、CORSの設定が要りません。ただし公開したRPCはブラウザから直接叩けるので、外部に出すなら前述の`requestGate`を同じアダプターに足します。`node:http`で立てているため、gRPCクライアントの受け口にはなりません。

## ブラウザからconnect-queryで直接呼ぶ設定

### TransportProviderとuseQueryの組み込み

connect-query v2は、[TanStack Query](/tech/details/10307/)の`useQuery`に、クエリキーと取得関数を自動で割り当てるラッパーです。ブラウザでは`@connectrpc/connect-web`のTransportを渡します。追加の生成プラグインは要らず、`GreetService.method.greet`をそのまま渡せます。React Router v8のルートとして書いた例です。

```
npm install @connectrpc/connect-query@2.3.1 @tanstack/react-query@5.106.0
```

```
// app/routes/client-greet.tsx
import { createConnectTransport } from "@connectrpc/connect-web";
import { TransportProvider, useQuery } from "@connectrpc/connect-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { GreetService } from "../gen/greet/v1/greet_pb";

const transport = createConnectTransport({ baseUrl: "https://api.example.com" });

function Greeting({ name }: { name: string }) {
  const { data, error, isPending } = useQuery(GreetService.method.greet, { name });
  if (isPending) return <p>loading...</p>;
  if (error) return <p>{error.code}: {error.rawMessage}</p>;
  return <p>{data.greeting}</p>;
}

export default function ClientGreet() {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <TransportProvider transport={transport}>
      <QueryClientProvider client={queryClient}>
        <Greeting name="Remix" />
      </QueryClientProvider>
    </TransportProvider>
  );
}
```

`error`は`ConnectError`型なので、`error.code`で分岐できます。

### 別オリジンのAPIを呼ぶときのCORS

別オリジンへJSONのPOSTや独自ヘッダー付きのリクエストを送ると、ブラウザは事前にプリフライト（OPTIONS）を送ります。`@connectrpc/connect`が公開する`cors`オブジェクトには、Connectの3プロトコルに必要な`allowedMethods`・`allowedHeaders`・`exposedHeaders`が定数で入っています。CORSミドルウェアにこれを渡し、`Authorization`などアプリ固有のヘッダーを足します。

gRPC-Webで既存のgRPCサーバーにつなぐ場合は、Envoyの`grpc_web`フィルタなどの変換プロキシが必要です。構成は[gRPC-Webの仕組みとプロキシ構成の解説](/tech/details/16253/)で扱っています。

## サーバー経由とブラウザ直呼びの使い分けの判断基準

既定はサーバー経由（React Router v8のloader、Remix 3のルートハンドラー）です。ブラウザからの直呼びは、理由がある画面に限ります。

| 観点               | サーバー経由    | ブラウザ直呼び          |
| ---------------- | --------- | ---------------- |
| CORS設定           | 不要        | 別オリジンなら必要        |
| APIの接続先・トークン     | サーバー内に閉じる | ブラウザに出る          |
| 初回HTMLへのデータ（掲載例） | 含まれる      | 含まれない            |
| 使えるプロトコル         | 3種すべて     | ConnectとgRPC-Web |
| 再取得の単位           | 遷移・再検証ごと  | クエリごと            |

ブラウザ直呼びを選んでよいのは、検索条件の入力に合わせて頻繁に再取得する画面や、同じデータを複数の部品で共有してキャッシュしたい画面です。社内のgRPCサービスをそのまま外へ出す目的で直呼びを選ぶのは避けます。認証・レート制限・CORSをバックエンドで新たに抱え、サーバー側で1層挟めば済む問題を広げるためです。外部公開のAPI設計から相談したい場合は、[API開発・システム連携の支援](/service/system/api/)も行っています。

よくある失敗は3つです。HTTP/1.1で立てたサーバーにgRPCのTransportを向けて`Protocol error`になるもの、v1の記事を見てprotoc-gen-connect-esを入れ、v2のランタイムと生成物が噛み合わなくなるもの、そしてinterceptorで認証して、未認証のリクエストの本文まで読み込んでしまうものです。tRPCなど他の型安全なRPCとの比較は[tRPCとgRPCの違いの解説](/tech/details/7937/)にあります。

## よくある質問

### protoc-gen-connect-esはまだ使えますか？

Connect-ES v2以降では使いません。v2.0.0でリポジトリから削除され、サービス定義は`@bufbuild/protoc-gen-es`が生成します。npmに残る1.7.0はv1系のランタイム向けです。

### connect-esとgRPC-Webは何が違いますか？

connect-esはTypeScript向けのConnect実装で、gRPC-Webはプロトコルの1つです。connect-esのブラウザ用パッケージは、Connectプロトコルの`createConnectTransport`とgRPC-Webの`createGrpcWebTransport`を両方提供します。

### RemixのloaderからgRPCサーバーを直接呼べますか？

呼べます。loaderはサーバーで動くので、`@connectrpc/connect-node`の`createGrpcTransport`で素のgRPCサーバーにも接続できます。相手のサーバーがHTTP/2で待ち受けていることが条件です。

### Remix 3でもConnect-ESは使えますか？

使えます。Remix 3.2.0のルートハンドラーから`connect-node`のクライアントで呼べることと、`connectNodeAdapter`の`fallback`でRemix 3と同じポートに同居させられることを確認しました。Remix 3はNode.js 24.3.0以上が必要です。

### ExpressやNext.jsでもConnectサーバーを動かせますか？

動かせます。`@connectrpc/connect-express`・`connect-fastify`・`connect-next`のアダプターがあり、ルートの定義は`connectNodeAdapter`と共通です。ただしExpressとNext.jsではgRPCプロトコルを受けられず、ConnectとgRPC-Webに限られます。

## 関連記事

- [Connect（connectrpc）とは？gRPC・RESTとの違いとconnect-go v2での使い方](/tech/details/5504/)
- [gRPC-Webとは？ブラウザからgRPCを呼ぶ仕組みとプロキシ構成・採用判断を解説](/tech/details/16253/)
- [gRPCとは？UDPは使うのかをTCP・HTTP/3で実測｜仕組み・RESTとの違い・実装例](/tech/details/3404/)
- [gRPCのステータスコードとは？16種の意味・HTTP対応表・リトライ可否を解説](/tech/details/17200/)
- [Remix 3のコンポーネントとは？3.0正式版のhandle・mixの書き方と@remix-run/ui](/tech/details/10504/)

---

出典: [gRPC×Connect-ESをRemixで使う実装｜React Router v8のloaderとRemix 3から呼ぶ【2.3.0実測】](<https://www.issoh.co.jp/tech/details/7063/>)（株式会社一創）
