tRPC で型安全な API を作れていても、Gemini API のストリーミングをどう乗せるかで詰まった経験はありませんか。readableStream を返す関数と tRPC の subscription を繋ぐ部分、Prisma でメッセージを保存するタイミング、Edge Runtime で動かすときの制約——それぞれは解決できても、全部組み合わせるとどこかで型エラーが出たり、ストリームが途中で途切れたりします。
私も個人プロダクトに同じ構成を組み込むとき、ドキュメントを読んで実験して壊してを繰り返しました。その過程でわかった「なぜ動かないか」と「なぜそう設計するか」を、動くコードと一緒にまとめたのがこの記事です。
tRPC + Gemini API が相性よい理由
tRPC の魅力はクライアントとサーバーの型共有です。Gemini API を直接 fetch で叩くと、レスポンスの型は any に近い状態になりがちで、フロント側のハンドリングが脆くなります。tRPC を介することで、Gemini のレスポンスを加工・正規化した後の型をクライアントまで一気に流せます。
Prisma との組み合わせも自然です。AI チャットは「会話セッション → メッセージ → ロール(user/model)」という階層構造を持ち、これはリレーショナル設計にぴったりです。Prisma の型生成と tRPC の型推論が合わさると、フロントエンドで session.messages[0].role と書いた瞬間に補完が効く状態が作れます。
プロジェクトのセットアップ
まず依存パッケージをインストールします。tRPC v11 は @trpc/server と @trpc/client を使います。
npm install @trpc/server@^11 @trpc/client@^11 @trpc/react-query@^11
npm install @tanstack/react-query@^5
npm install @prisma/client prisma
npm install @google/genai
npm install zod
npm install superjson
# 開発用
npx prisma init
Next.js App Router 向けのルート構成は以下のようになります。
src/
server/
trpc.ts # tRPC インスタンス定義
routers/
_app.ts # ルートルーター
chat.ts # チャット機能のルーター
app/
api/
trpc/
[trpc]/
route.ts # Next.js Route Handler
lib/
prisma.ts # Prisma クライアントシングルトン
trpc/
client.ts # クライアント側 tRPC 設定
Prisma スキーマ設計
AI チャットの永続化に必要なモデルを定義します。ポイントは Message の role フィールドを enum で管理し、ストリーミング中の途中状態を status で追跡できるようにする点です。
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env ( "DATABASE_URL" )
}
enum Role {
USER
MODEL
SYSTEM
}
enum MessageStatus {
PENDING // ストリーミング中
COMPLETED // 完了
FAILED // エラー
}
model User {
id String @id @default ( cuid ())
email String @unique
createdAt DateTime @default ( now ())
sessions ChatSession []
}
model ChatSession {
id String @id @default ( cuid ())
userId String
title String @default ( "新しい会話" )
createdAt DateTime @default ( now ())
updatedAt DateTime @updatedAt
user User @relation ( fields : [ userId ], references : [ id ], onDelete : Cascade )
messages Message []
@@index ([ userId , createdAt ( sort : Desc )])
}
model Message {
id String @id @default ( cuid ())
sessionId String
role Role
content String @db.Text
status MessageStatus @default ( PENDING )
tokenCount Int ?
createdAt DateTime @default ( now ())
session ChatSession @relation ( fields : [ sessionId ], references : [ id ], onDelete : Cascade )
@@index ([ sessionId , createdAt ])
}
status: PENDING にしてから Gemini API を呼び出し、ストリームが完了したら COMPLETED に更新する——このパターンにより、ネットワーク切断で中途半端な状態が残った場合でも UI 側で「送信中」と「完了」を区別できます。
マイグレーションは以下で実行します。
npx prisma migrate dev --name init
npx prisma generate # Prisma Client を生成
tRPC インスタンスとコンテキスト
認証トークンを Request ヘッダーから読み取り、コンテキストに注入する設定です。
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server" ;
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch" ;
import { prisma } from "@/lib/prisma" ;
import superjson from "superjson" ;
// コンテキスト型
export async function createContext ({ req } : FetchCreateContextFnOptions ) {
const authHeader = req.headers. get ( "Authorization" );
const token = authHeader?. replace ( "Bearer " , "" );
// デモ用: 実際はJWTやセッションで検証する
const userId = token ? await verifyToken (token) : null ;
return { userId, prisma, req };
}
export type Context = Awaited < ReturnType < typeof createContext>>;
// tRPC インスタンス
const t = initTRPC. context < Context >(). create ({
transformer: superjson,
errorFormatter ({ shape , error }) {
return {
... shape,
data: {
... shape.data,
// Zod バリデーションエラーを整形
zodError:
error.cause instanceof ZodError ? error.cause. flatten () : null ,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
// 認証済みユーザーのみアクセス可能なプロシージャ
export const protectedProcedure = t.procedure. use (({ ctx , next }) => {
if ( ! ctx.userId) {
throw new TRPCError ({
code: "UNAUTHORIZED" ,
message: "ログインが必要です" ,
});
}
return next ({
ctx: {
... ctx,
userId: ctx.userId, // 型を narrowing
},
});
});
// レート制限ミドルウェア(1分あたり20リクエストまで)
const rateLimitMap = new Map < string , { count : number ; resetAt : number }>();
export const rateLimitedProcedure = protectedProcedure. use (
({ ctx , next }) => {
const now = Date. now ();
const windowMs = 60 * 1000 ; // 1分
const limit = 20 ;
const entry = rateLimitMap. get (ctx.userId) ?? {
count: 0 ,
resetAt: now + windowMs,
};
if (now > entry.resetAt) {
entry.count = 0 ;
entry.resetAt = now + windowMs;
}
entry.count ++ ;
rateLimitMap. set (ctx.userId, entry);
if (entry.count > limit) {
throw new TRPCError ({
code: "TOO_MANY_REQUESTS" ,
message: `レート制限: ${ Math . ceil (( entry . resetAt - now ) / 1000 ) }秒後に再試行してください` ,
});
}
return next ({ ctx });
}
);
async function verifyToken ( token : string ) : Promise < string | null > {
// 実装はJWTライブラリや NextAuth.js のセッション検証に置き換える
// ここではデモ用の仮実装
try {
const decoded = Buffer. from (token, "base64" ). toString ( "utf-8" );
return decoded || null ;
} catch {
return null ;
}
}
ここで重要なのは rateLimitMap をメモリに保持している点です。本番環境では複数インスタンスにスケールアウトするため、Redis やその他の共有ストアに置き換える必要があります。ただし、検証段階では十分機能します。
チャットルーターと Gemini API 統合
Gemini API の呼び出しと tRPC の手続き定義を組み合わせるルーターです。
// src/server/routers/chat.ts
import { z } from "zod" ;
import { router, protectedProcedure, rateLimitedProcedure } from "../trpc" ;
import { GoogleGenAI } from "@google/genai" ;
import { observable } from "@trpc/server/observable" ;
const genai = new GoogleGenAI ({
apiKey: process.env. GEMINI_API_KEY ! ,
});
export const chatRouter = router ({
// セッション一覧取得
listSessions: protectedProcedure. query ( async ({ ctx }) => {
return ctx.prisma.chatSession. findMany ({
where: { userId: ctx.userId },
orderBy: { updatedAt: "desc" },
take: 50 ,
include: {
_count: { select: { messages: true } },
},
});
}),
// セッション作成
createSession: protectedProcedure
. input (z. object ({ title: z. string (). max ( 100 ). optional () }))
. mutation ( async ({ ctx , input }) => {
return ctx.prisma.chatSession. create ({
data: {
userId: ctx.userId,
title: input.title ?? "新しい会話" ,
},
});
}),
// メッセージ送信(通常のquery/mutation — ストリーミングなし)
sendMessage: rateLimitedProcedure
. input (
z. object ({
sessionId: z. string (). cuid (),
content: z. string (). min ( 1 ). max ( 10000 ),
})
)
. mutation ( async ({ ctx , input }) => {
// セッションの所有権確認
const session = await ctx.prisma.chatSession. findFirst ({
where: { id: input.sessionId, userId: ctx.userId },
include: {
messages: {
orderBy: { createdAt: "asc" },
take: 20 , // 直近20件を会話履歴に使用
},
},
});
if ( ! session) {
throw new TRPCError ({
code: "NOT_FOUND" ,
message: "セッションが見つかりません" ,
});
}
// ユーザーメッセージを保存
const userMessage = await ctx.prisma.message. create ({
data: {
sessionId: input.sessionId,
role: "USER" ,
content: input.content,
status: "COMPLETED" ,
},
});
// AI メッセージのプレースホルダーを作成(PENDING状態)
const aiMessage = await ctx.prisma.message. create ({
data: {
sessionId: input.sessionId,
role: "MODEL" ,
content: "" ,
status: "PENDING" ,
},
});
try {
// 会話履歴を Gemini API の形式に変換
const history = session.messages. map (( msg ) => ({
role: msg.role === "USER" ? "user" : "model" ,
parts: [{ text: msg.content }],
}));
const chat = genai
. getGenerativeModel ({ model: "gemini-2.5-pro" })
. startChat ({
history,
generationConfig: {
maxOutputTokens: 4096 ,
temperature: 0.7 ,
},
});
const result = await chat. sendMessage (input.content);
const responseText = result.response. text ();
const usageMetadata = result.response.usageMetadata;
// AI メッセージを更新
const updatedAiMessage = await ctx.prisma.message. update ({
where: { id: aiMessage.id },
data: {
content: responseText,
status: "COMPLETED" ,
tokenCount: usageMetadata?.candidatesTokenCount ?? null ,
},
});
// セッションのタイムスタンプ更新
await ctx.prisma.chatSession. update ({
where: { id: input.sessionId },
data: { updatedAt: new Date () },
});
return {
userMessage,
aiMessage: updatedAiMessage,
};
} catch (error) {
// エラー時は AI メッセージを FAILED に更新
await ctx.prisma.message. update ({
where: { id: aiMessage.id },
data: { status: "FAILED" , content: "エラーが発生しました" },
});
throw new TRPCError ({
code: "INTERNAL_SERVER_ERROR" ,
message: "AI の応答に失敗しました" ,
});
}
}),
});
sendMessage を一度実装した後、多くの開発者がストリーミング版を欲しがります。tRPC v11 の subscription を使うと、チャンク単位でクライアントへ送信できます。
tRPC Subscription でリアルタイムストリーミング
tRPC の subscription と Gemini API のストリーミングを組み合わせる実装です。これが最も詰まりやすい部分です。
// src/server/routers/chat.ts(続き)
// ストリーミング送信
streamMessage : rateLimitedProcedure
. input (
z. object ({
sessionId: z. string (). cuid (),
content: z. string (). min ( 1 ). max ( 10000 ),
messageId: z. string (). cuid (). optional (), // 再接続時の既存ID
})
)
. subscription (({ ctx , input }) => {
return observable <
| { type : "chunk" ; text : string }
| { type : "done" ; messageId : string; tokenCount : number }
| { type : "error" ; message : string }
> (( emit ) => {
let cancelled = false ;
let aiMessageId : string | null = null ;
let fullText = "" ;
const run = async () => {
try {
// セッション確認
const session = await ctx.prisma.chatSession. findFirst ({
where: { id: input.sessionId, userId: ctx.userId },
include: {
messages: {
orderBy: { createdAt: "asc" },
take: 20 ,
where: { status: "COMPLETED" },
},
},
});
if ( ! session) {
emit. next ({ type: "error" , message: "セッションが見つかりません" });
emit. complete ();
return ;
}
// ユーザーメッセージ保存
await ctx.prisma.message. create ({
data: {
sessionId: input.sessionId,
role: "USER" ,
content: input.content,
status: "COMPLETED" ,
},
});
// AI メッセージのプレースホルダー
const aiMessage = await ctx.prisma.message. create ({
data: {
sessionId: input.sessionId,
role: "MODEL" ,
content: "" ,
status: "PENDING" ,
},
});
aiMessageId = aiMessage.id;
const history = session.messages. map (( msg ) => ({
role: msg.role === "USER" ? "user" : "model" ,
parts: [{ text: msg.content }],
}));
const chat = genai
. getGenerativeModel ({ model: "gemini-2.5-pro" })
. startChat ({ history });
// ストリーミングレスポンスを取得
const stream = await chat. sendMessageStream (input.content);
let totalTokens = 0 ;
for await ( const chunk of stream.stream) {
if (cancelled) break ;
const text = chunk. text ();
if (text) {
fullText += text;
emit. next ({ type: "chunk" , text });
}
}
// 最終レスポンスからトークン数を取得
const finalResponse = await stream.response;
totalTokens =
finalResponse.usageMetadata?.candidatesTokenCount ?? 0 ;
if ( ! cancelled) {
// DB を最終テキストで更新
await ctx.prisma.message. update ({
where: { id: aiMessageId },
data: {
content: fullText,
status: "COMPLETED" ,
tokenCount: totalTokens,
},
});
await ctx.prisma.chatSession. update ({
where: { id: input.sessionId },
data: { updatedAt: new Date () },
});
emit. next ({
type: "done" ,
messageId: aiMessageId,
tokenCount: totalTokens,
});
emit. complete ();
}
} catch (error) {
// エラー時のクリーンアップ
if (aiMessageId) {
await ctx.prisma.message
. update ({
where: { id: aiMessageId },
data: { status: "FAILED" , content: fullText || "エラーが発生しました" },
})
. catch (() => {});
}
emit. next ({
type: "error" ,
message:
error instanceof Error
? error.message
: "ストリーミングに失敗しました" ,
});
emit. complete ();
}
};
run ();
// クリーンアップ関数(クライアントが切断した際に呼ばれる)
return () => {
cancelled = true ;
};
});
}),
cancelled フラグを使ってクライアント切断時にストリームを止めている点がポイントです。切断後も Gemini API が応答し続けるのを防ぐために、for await ループを早期終了させます。ただし Gemini API 側は既にリクエストを開始しているため、課金は発生します。
Route Handler の設定
Next.js App Router で tRPC を動かすための Route Handler です。
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch" ;
import { appRouter } from "@/server/routers/_app" ;
import { createContext } from "@/server/trpc" ;
const handler = ( req : Request ) =>
fetchRequestHandler ({
endpoint: "/api/trpc" ,
req,
router: appRouter,
createContext : ({ req , resHeaders }) => createContext ({ req, resHeaders }),
onError:
process.env. NODE_ENV === "development"
? ({ path , error }) => {
console. error ( `tRPC error on ${ path }:` , error);
}
: undefined ,
});
export { handler as GET, handler as POST };
クライアント側の設定
クライアント側で subscription を使う場合は WebSocket ではなく Server-Sent Events(SSE)経由になります。tRPC v11 から SSE がファーストクラスサポートされています。
// src/trpc/client.ts
"use client" ;
import {
createTRPCClient,
httpBatchStreamLink,
unstable_httpSubscriptionLink,
splitLink,
} from "@trpc/client" ;
import type { AppRouter } from "@/server/routers/_app" ;
import superjson from "superjson" ;
const url = process.env. NEXT_PUBLIC_APP_URL + "/api/trpc" ;
export const trpc = createTRPCClient < AppRouter >({
links: [
splitLink ({
// subscription は SSE リンクを使用
condition : ( op ) => op.type === "subscription" ,
true: unstable_httpSubscriptionLink ({
url,
transformer: superjson,
}),
false: httpBatchStreamLink ({
url,
transformer: superjson,
headers () {
// Cookie や localStorage からトークンを取得
const token =
typeof window !== "undefined"
? localStorage. getItem ( "auth_token" )
: null ;
return token ? { Authorization: `Bearer ${ token }` } : {};
},
}),
}),
],
});
React コンポーネントからの呼び出し例です。
// src/components/ChatInput.tsx
"use client" ;
import { useState } from "react" ;
import { trpc } from "@/trpc/client" ;
export function ChatInput ({ sessionId } : { sessionId : string }) {
const [ input , setInput ] = useState ( "" );
const [ streaming , setStreaming ] = useState ( false );
const [ streamedText , setStreamedText ] = useState ( "" );
const handleSubmit = async ( e : React . FormEvent ) => {
e. preventDefault ();
if ( ! input. trim () || streaming) return ;
setStreaming ( true );
setStreamedText ( "" );
// subscription を手動で開始する
const subscription = trpc.chat.streamMessage. subscribe (
{ sessionId, content: input },
{
onData ( data ) {
if (data.type === "chunk" ) {
setStreamedText (( prev ) => prev + data.text);
} else if (data.type === "done" ) {
setStreaming ( false );
// ここでメッセージリストを再フェッチするなど
} else if (data.type === "error" ) {
console. error (data.message);
setStreaming ( false );
}
},
onError ( err ) {
console. error (err);
setStreaming ( false );
},
onComplete () {
setStreaming ( false );
},
}
);
setInput ( "" );
// コンポーネントアンマウント時にサブスクリプションを解除する場合は
// subscription.unsubscribe() を呼ぶ
};
return (
< form onSubmit = { handleSubmit } >
< input
value = { input }
onChange = { ( e ) => setInput (e.target.value) }
disabled = { streaming }
placeholder = "メッセージを入力..."
/>
< button type = "submit" disabled = { streaming || ! input. trim () } >
{ streaming ? "送信中..." : "送信" }
</ button >
{ streamedText && < div className = "streaming-text" > { streamedText } </ div > }
</ form >
);
}
よくある落とし穴
実際に組み込んでいくと必ずぶつかるポイントを3つ挙げます。
落とし穴1: Edge Runtime での Prisma 非互換
Next.js の Route Handler を Edge Runtime に設定すると、Prisma Client が動きません。Prisma は Node.js の fs や native modules に依存しているため、Edge Runtime(V8 isolate ベース)では実行できないのです。
// ❌ Edge Runtime では Prisma が動かない
export const runtime = "edge" ;
// ✅ Node.js Runtime を使う
export const runtime = "nodejs" ; // または省略(デフォルト)
Gemini API 自体は Edge Runtime でも動きますが、DB アクセスが必要な場合は Node.js Runtime を使いましょう。どうしても Edge で動かしたい場合は、Prisma Accelerate(接続プーリングサービス)か、PlanetScale や Neon のような HTTP ベースのドライバーに切り替える必要があります。
落とし穴2: tRPC subscription の型が崩れる
observable のジェネリック型を明示しないと、emit.next() に渡す値の型チェックが機能しません。
// ❌ 型がなくて事故りやすい
return observable (( emit ) => { ... });
// ✅ ユニオン型を明示する
return observable <
| { type : "chunk" ; text : string }
| { type : "done" ; messageId : string; tokenCount : number }
| { type : "error" ; message : string }
> (( emit ) => { ... });
クライアント側で data.type === "chunk" と narrowing したときに data.text が補完される——これが型安全の醍醐味です。
落とし穴3: for await ループ内での Prisma 呼び出し
ストリームのチャンクを受け取るたびに DB を更新したくなりますが、これはパフォーマンス上の罠です。
// ❌ チャンクのたびに DB を叩く(100チャンクで100回のクエリ)
for await ( const chunk of stream.stream) {
fullText += chunk. text ();
await prisma.message. update ({ where: { id }, data: { content: fullText } });
}
// ✅ ストリーム完了後に1回だけ更新する
for await ( const chunk of stream.stream) {
fullText += chunk. text ();
emit. next ({ type: "chunk" , text: chunk. text () });
}
// 完了後にまとめて保存
await prisma.message. update ({ where: { id }, data: { content: fullText, status: "COMPLETED" } });
ただし、「ユーザーがリロードしたときに途中のテキストを復元したい」という要件があるなら、一定間隔(例: 10秒ごと)でスナップショット保存する折衷案も検討できます。
トークン使用量とコストを記録時に可視化する
ユーザーごとにトークン予算を設けたい、あるいは API コストを把握したいという要件が出てきたとき、頼りになるのが Message モデルの tokenCount フィールドです。応答を保存する瞬間に出力トークン数を一緒に書き込んでおけば、あとから API ログを突き合わせて再計算するよりもはるかに安く、正確に集計できます。
私自身、個人開発で運用しているサービスにこの構成を載せたとき、月末に Google Cloud の請求を見て初めてコストの内訳に気づく、という後手の運用を続けていました。そこで「請求が来てから知る」のではなく「書き込んだ時点で見える」状態に変えるべく、tRPC のクエリ手続きとして利用状況の集計を切り出しました。
// 利用状況の集計手続き
getUsageStats : protectedProcedure
. input (
z. object ({
since: z. date (). optional (), // 例: 今月の初日
})
)
. query ( async ({ ctx , input }) => {
const since = input.since ?? new Date ( new Date (). setDate ( 1 )); // 既定: 今月
const result = await ctx.prisma.message. aggregate ({
_sum: { tokenCount: true },
_count: { id: true },
where: {
session: { userId: ctx.userId },
role: "MODEL" , // 出力トークンのみを対象にする
status: "COMPLETED" ,
createdAt: { gte: since },
},
});
const outputTokens = result._sum.tokenCount ?? 0 ;
const messageCount = result._count.id;
// Gemini 2.5 Pro の出力料金: 約 $15 / 100万トークン
const estimatedCostUsd = (outputTokens / 1_000_000 ) * 15 ;
return {
outputTokens,
messageCount,
estimatedCostUsd,
period: { from: since, to: new Date () },
};
}),
ここで role: "MODEL" に絞っているのは、コストを押し上げるのはほぼ出力トークンだからです。入力トークンも料金には乗りますが、チャット用途では出力側の比重が大きく、私の運用では月間の推定コストの約 70〜80% が出力トークン由来でした。まず出力を握るだけでも、課金設計の見通しは立ちます。
この集計があると、定額の月額にするにしても従量課金にするにしても、「この価格でなぜ成り立つのか」を自分の言葉で説明できるようになります。逆に言えば、tokenCount を保存し忘れた期間のコストは後から復元できません。個人開発のように一人で運用と会計の両方を見ている場合はなおさら、永続化レイヤーを設計する最初の段階で、トークン数の保存を必ず組み込んでおくことをおすすめします。
本番デプロイ時の考慮点
Vercel にデプロイする場合、tRPC の SSE(Server-Sent Events)は Vercel の Streaming Response 機能を使います。無料プランでは最大60秒のレスポンスタイムアウトが適用されるため、長い Gemini API の応答はタイムアウトする可能性があります。
対策として、長い処理はバックグラウンドジョブ(Vercel Cron や Cloud Tasks)に切り出し、tRPC からは「ジョブIDを返す」だけにする設計も有効です。実際の Gemini API の応答速度は Next.js 15 + Gemini の本番実装 で検証した経験から、2.5 Pro モデルは平均5〜15秒で完了するケースが多く、通常のチャット用途では問題になりにくいです。
環境変数の管理は必ず .env.local(開発)と Vercel の Environment Variables(本番)を分けましょう。Prisma の接続文字列は DATABASE_URL に、Gemini API キーは GEMINI_API_KEY にそれぞれ設定します。本番では Prisma Accelerate を使ってコネクションプーリングを有効にすることを強くおすすめします。
Prisma マイグレーションの本番実行は、デプロイ前のビルドステップで実行します。
// package.json
{
"scripts" : {
"build" : "prisma generate && prisma migrate deploy && next build" ,
"postinstall" : "prisma generate"
}
}
エラーハンドリングの詳細なパターンについては Gemini API エラー処理とレート制限の本番パターン もあわせて参照してみてください。ストリーミング中の切断リカバリや指数バックオフの実装例が詳しく解説されています。
全体を振り返って
tRPC v11 + Gemini API + Prisma の組み合わせは、型安全と開発速度を両立させる構成として実際のプロダクトに十分耐えます。最初のステップとして、sendMessage のシンプルな mutation を動かし、型推論がフロントエンドまで届くことを確認してみてください。そこから subscription によるストリーミングに進むと、設計の意図が掴みやすくなるはずです。