gRPC×Connect-ESをRemix(React Router v8)で使う実装|v2の生成手順とloader連携

gRPC×Connect-ESをRemix(React Router v8)で使う実装|v2の生成手順とloader連携

RemixからgRPCのサービスを呼ぶなら、2026年時点で組みやすいのはConnect-ES v2です。Connectのサーバーは1つの実装でConnect・gRPC・gRPC-Webの3プロトコルを受け付けるため、Remixのloaderからもブラウザのfetchからも、同じprotoの型のまま呼び出せます。

ただし、Connect-ES v1向けのコード生成手順やRemix v2向けのimportは、本記事の構成に合わせて変更が必要です。コード生成プラグインのprotoc-gen-connect-esはv2で削除され、Remix v2はReact Router v7へ統合されました。本記事では、v2のコード生成、Connect-Nodeサーバー、React Router v8のloaderとconnect-queryからの呼び出しまでを、実際に動かしたコードと出力で示します。

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

  • コード生成は@bufbuild/protoc-gen-esだけで足ります。v2ではサービス定義も*_pb.tsに出力され、protoc-gen-connect-esは不要です。
  • サーバーは@connectrpc/connect-nodeconnectNodeAdapterで立てます。既定でConnect・gRPC・gRPC-Webを同時に受け付けます。
  • HTTP/1.1のサーバーにgRPCクライアントはつながりません(実測で[internal] Protocol error)。gRPCの呼び出し元があるならHTTP/2で待ち受けます。
  • Remix(React Router v8)からは、loaderでサーバー側のConnectクライアントを呼ぶ構成を基本にします。CORSが不要で、APIの接続先をブラウザに出しません。
  • 画面操作に合わせた再取得やキャッシュが要る画面だけ、ブラウザから@connectrpc/connect-queryで直接呼びます。

Remix×gRPC×Connectの構成と2026年時点の前提

gRPCはHTTP/2のトレーラーに依存するため、ブラウザのfetchからは素のgRPCを呼べません。Connectはこの制約を、同じサーバーが3種類のプロトコルを話すことで解決します。gRPCそのものの仕組みはgRPCの定義と仕組みの解説、Connectの位置づけやGo実装はConnect(connectrpc)の解説にまとめています。

ConnectとgRPC・gRPC-WebのHTTPバージョン別の到達性

HTTP/1.1で待ち受けるConnect-Nodeサーバー(node:http)に、クライアントの各Transportから接続した結果です。

Transport HTTP/1.1サーバー HTTP/2サーバー ブラウザから
Connect(POST) 成功 成功
Connect(GET) 成功 成功
gRPC-Web 成功 成功
gRPC Protocol error 成功 不可

Connect-Nodeの公式ドキュメントも、HTTP/1.1ではgRPCプロトコルと双方向ストリーミングを提供できないと明記しています。ブラウザしか呼び出し元がないなら、HTTP/1.1のままで困りません。

RemixとReact Router v7・v8の関係

Remixチームは2024年5月15日、Remix v3として予定していた機能をReact Router v7として出すと発表し、v7の安定版を2024年11月22日に公開しました。現行はReact Router v8(2026年6月17日公開、公式の変更履歴)で、Node.js 22.22.0以上・React 19.2.7以上・Vite 7以上が必要です。react-router-domパッケージも削除されました。

別物として開発中のRemix 3は、2026年8月31日に最初のリリース候補が出た段階です。本記事のloaderとRoute型はReact Router v8のAPIで書いています。Remix v2からの移行判断はRemix SPAモードとReact Router移行の解説も参考になります。

検証に使ったバージョン

本記事のコードと出力は、2026年9月14日に次の環境で実行したものです。

パッケージ バージョン
Node.js 26.5.0
@connectrpc/connect/connect-node/connect-web 2.2.0
@bufbuild/protobuf/protoc-gen-es 2.15.0
@bufbuild/buf 1.73.0
react-router/@react-router/dev 8.3.1
@connectrpc/connect-query 2.3.1

@connectrpc/connect-node 2.2.0はpackage.jsonでNode.js 22以上を要求します。

Connect-ES v2のコード生成手順(protoc-gen-connect-es不要)

インストールするパッケージ

実行時に使うライブラリと、コード生成用の開発依存を分けて入れます。検証環境を再現する場合は、以下のインストールコマンドでConnect系を2.2.0、Protobuf系を2.15.0、Buf CLIを1.73.0に固定し、生成されたpackage-lock.jsonも保存してください。

npm install @connectrpc/[email protected] @connectrpc/[email protected] @connectrpc/[email protected] @bufbuild/[email protected]
npm install -D @bufbuild/[email protected] @bufbuild/[email protected]

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は2024年11月20日に正式公開されました。公式の移行ガイドは、protoc-gen-connect-esをpackage.jsonとbuf.gen.yamlの両方から削除するよう求めています。npmには@connectrpc/protoc-gen-connect-es 1.7.0が残っていますが、v2のランタイムとは組み合わせません。

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

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

connectNodeAdapterによるサーバー実装

Node.js標準のnode:httpにルートを渡すと、HTTP/1.1でConnect・gRPC-Webを受け付けるサーバーになります。gRPCも受け付けるには、後述のHTTP/2構成が必要です。入力が不正な場合はConnectErrorにコードを付けて投げます。

// src/server.ts
import { createServer } from "node:http";
import { connectNodeAdapter } from "@connectrpc/connect-node";
import type { ConnectRouter } from "@connectrpc/connect";
import { ConnectError, Code } from "@connectrpc/connect";
import { GreetService } from "./gen/greet/v1/greet_pb";

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

createServer(connectNodeAdapter({ routes })).listen(8080);

Express・Fastify・Next.jsに載せる場合は、それぞれ@connectrpc/connect-expressconnect-fastifyconnect-nextのアダプターを使います。

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

サーバーコードを保存したら、APIプロジェクトのルートでnpm install -D [email protected]を実行し、npx tsx src/server.tsで起動します。サーバーを起動したまま、別のターミナルで以下のcurlを実行してください。

ConnectプロトコルのunaryはJSONのPOSTで呼べるため、専用ツールなしでcurlから確認できます。以下のコメントはレスポンス例です。HTTPステータスとヘッダーも表示する場合は、各curlコマンドに-iを追加してください。パスは/パッケージ名.サービス名/メソッド名です。

curl -X POST http://localhost:8080/greet.v1.GreetService/Greet \
  -H "Content-Type: application/json" -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" -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 '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で接続するとConnectError: [internal] Protocol errorで失敗し、次のHTTP/2サーバーでは同じ呼び出しが成功しました。

// src/server_h2.ts
import { createServer } from "node:http2";
import { connectNodeAdapter } from "@connectrpc/connect-node";
import { GreetService } from "./gen/greet/v1/greet_pb";

createServer(
  connectNodeAdapter({
    routes: (router) =>
      router.service(GreetService, {
        async greet(req) {
          return { greeting: `Hello, ${req.name}` };
        },
      }),
  }),
).listen(8081);

この例は平文のHTTP/2(h2c)です。本番でブラウザも同じポートに来るなら、TLS終端をどこに置くかを先に決めます。

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

以下は、SSRを有効にしたReact Router v8のFramework Modeプロジェクトを前提にします。先ほど生成したsrc/genを、このプロジェクトのapp/genへコピーしてください。以下のクライアントコードは、その配置を参照します。

app/routes.tsでは、@react-router/dev/routesindexを使ってindex("routes/home.tsx")を登録します。ブラウザ直呼びの例も表示する場合は、同パッケージのrouteを使ってroute("client-greet", "routes/client-greet.tsx")を追加します。

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

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 buildServer-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 });
    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で型を生成し、ビルドして起動した結果、/?name=Remixは200でHello, Remixを表示し、nameなしは400を返しました。

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

Connectのコード HTTPステータス
invalid_argument 400
unauthenticated 401
permission_denied 403
not_found 404
resource_exhausted 429
unavailable 503
deadline_exceeded 504

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

TransportProviderとuseQueryの組み込み

connect-query v2は、TanStack QueryのuseQueryにクエリキーと取得関数を自動で割り当てるラッパーです。ブラウザでは@connectrpc/connect-webのTransportを渡します。追加の生成プラグインを入れなくても、GreetService.method.greetをそのまま渡せます。

npm install @connectrpc/[email protected] @tanstack/[email protected]
// 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>
  );
}

errorConnectError型なので、error.codeで分岐できます。

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

別オリジンへJSONのPOSTや独自ヘッダー付きのリクエストを送る場合、ブラウザは必要に応じてプリフライトを送ります。GETなどでCORSのセーフリスト条件を満たすリクエストは、プリフライトなしで送信されます。@connectrpc/connectが公開するcorsオブジェクトには、Connectの3プロトコルに必要なallowedMethodsallowedHeadersexposedHeadersが定数で入っています。CORSミドルウェアにこれを渡し、アプリ固有の認証ヘッダーなどを追加します。

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

loader経由とブラウザ直呼びの使い分けの判断基準

既定はloader経由です。ブラウザからの直呼びは、理由がある画面に限ります。

観点 loader経由 ブラウザ直呼び
CORS設定 不要 別オリジンの場合に必要
APIの接続先・資格情報 サーバー内に閉じる ブラウザに出る
初回HTMLへのデータ 含まれる 含まれない
使えるプロトコル 3種すべて ConnectとgRPC-Web
操作ごとの再取得 遷移・revalidate単位 クエリ単位で細かく

ブラウザ直呼びを選んでよいのは、検索条件の入力に合わせて頻繁に再取得する、同じデータを複数の部品で共有してキャッシュしたい、といった画面です。逆に、社内のgRPCサービスをそのまま外に出すためにブラウザ直呼びを選ぶのは避けます。認証・レート制限・CORSをバックエンド側で新たに抱えることになり、loaderが1層挟めば済む問題を広げます。

よくある失敗は2つです。1つはHTTP/1.1で立てたサーバーにgRPCのTransportを向けてProtocol errorになるもの、もう1つはv1の記事を見てprotoc-gen-connect-esを入れ、v2のランタイムと生成物が噛み合わなくなるものです。どちらも前述の表と生成手順どおりに揃えれば起きません。tRPCなど他の型安全なRPCとの比較はtRPCとgRPCの違いの解説にあります。

よくある質問

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-nodecreateGrpcTransportで素のgRPCサーバーにも接続できます。その場合、相手のサーバーはHTTP/2で待ち受けている必要があります。

Connectサーバーに既存のgRPCクライアントから接続できますか?

できます。Connect-Nodeのサーバーは既定でgRPCも受け付けます。ただしHTTP/1.1で待ち受けているとgRPCは通らないため、node:http2で立てます。

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

動かせます。@connectrpc/connect-expressconnect-fastifyconnect-nextのアダプターがあり、ルートの定義はconnectNodeAdapterと共通です。

関連記事

資料請求

RELATED POSTS 関連記事