今回やること
tRPC 11で、投稿を扱う2つのprocedureを作ります。
post.list: 認証なしで投稿一覧を読むQuerypost.create: 認証済み利用者だけが投稿を作るMutation
Node.jsのstandalone HTTP serverとclientを別プロセスで動かし、server側のAppRouter型がclientへ伝わる流れを確認します。データはメモリーに保存するため、データベースは不要です。
前提条件
- Node.js 20以上
- npm
- TypeScriptの型、
async/await、HTTP headerの基本が分かる
プロジェクトを準備する
mkdir trpc-post-practice
cd trpc-post-practice
npm init -y
npm install @trpc/server@11 @trpc/client@11 zod
npm install --save-dev typescript tsx @types/node
mkdir src
package.jsonへESM設定とスクリプトを追加します。依存関係はnpm installが作った内容を残してください。
{
"type": "module",
"scripts": {
"server": "tsx src/server.ts",
"client": "tsx src/client.ts"
}
}
tsconfig.jsonを作ります。
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"strict": true,
"noEmit": true
},
"include": ["src"]
}
Routerを作る
src/router.tsを作ります。
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
export type Context = {
userId: string | null;
};
type Post = {
id: number;
title: string;
authorId: string;
};
const posts: Post[] = [
{ id: 1, title: "最初の投稿", authorId: "user-1" },
];
const t = initTRPC.context<Context>().create();
const publicProcedure = t.procedure;
const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
if (ctx.userId === null) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
userId: ctx.userId,
},
});
});
export const appRouter = t.router({
post: t.router({
list: publicProcedure.query(() => posts),
create: protectedProcedure
.input(
z.object({
title: z.string().trim().min(1).max(80),
}),
)
.mutation(({ ctx, input }) => {
const post: Post = {
id: posts.length + 1,
title: input.title,
authorId: ctx.userId,
};
posts.push(post);
return post;
}),
}),
});
export type AppRouter = typeof appRouter;
publicProcedureは誰でも呼べます。protectedProcedureはmiddlewareでctx.userIdを確認し、未認証ならUNAUTHORIZEDを返します。post.createは利用者IDを入力で受け取らず、認証結果のcontextを使います。
ZodはHTTPから届いた入力を実行時に検証します。TypeScriptの型だけでは、不正な外部入力を止められません。
HTTP serverを作る
src/server.tsを作ります。
import {
type CreateHTTPContextOptions,
createHTTPServer,
} from "@trpc/server/adapters/standalone";
import { appRouter, type Context } from "./router";
function createContext({ req }: CreateHTTPContextOptions): Context {
const authorization = req.headers.authorization;
// 学習専用: 固定tokenを固定利用者user-1へ対応させる
const userId =
authorization === "Bearer learning-token" ? "user-1" : null;
return { userId };
}
const server = createHTTPServer({
router: appRouter,
createContext,
});
server.listen(3000, () => {
console.log("tRPC server: http://localhost:3000");
});
learning-tokenは動作確認専用です。本番では固定文字列を認証に使わず、署名を検証したsessionやaccess tokenから利用者IDを決めます。
型付きclientを作る
src/client.tsを作ります。
import {
createTRPCClient,
httpBatchLink,
TRPCClientError,
} from "@trpc/client";
import type { AppRouter } from "./router";
const url = "http://localhost:3000";
const publicClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url,
}),
],
});
const authenticatedClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url,
headers: {
authorization: "Bearer learning-token",
},
}),
],
});
console.log("before:", await publicClient.post.list.query());
try {
await publicClient.post.create.mutate({ title: "作成できない投稿" });
} catch (error: unknown) {
if (error instanceof TRPCClientError) {
console.log("without token:", error.data?.code);
}
}
const created = await authenticatedClient.post.create.mutate({
title: "tRPCで作った投稿",
});
console.log("created:", created);
console.log("after:", await publicClient.post.list.query());
serverとclientは同じhttp://localhost:3000を使います。clientのheadersがserverのreq.headers.authorizationへ届き、contextのuserIdに変換されます。
この例は標準のJSON変換を両側で使い、独自transformerを設定していません。DateなどJSONだけで表現しにくい値を扱う場合は、serverとclientの両方へ同じtransformer設定が必要です。
成功を確認する
1つ目のターミナルでserverを起動します。
npm run server
2つ目のターミナルでclientを実行します。
npm run client
次の3点を確認できれば成功です。
before: [ { id: 1, title: '最初の投稿', authorId: 'user-1' } ]
without token: UNAUTHORIZED
created: { id: 2, title: 'tRPCで作った投稿', authorId: 'user-1' }
after: [
{ id: 1, title: '最初の投稿', authorId: 'user-1' },
{ id: 2, title: 'tRPCで作った投稿', authorId: 'user-1' }
]
Zodの検証も確認する場合は、clientの作成タイトルを空文字へ一時的に変えます。
await authenticatedClient.post.create.mutate({ title: "" });
実行するとBAD_REQUESTになり、投稿は追加されません。確認後は元へ戻してください。
よくあるエラー
fetch failedになる
先にnpm run serverを実行し、serverを起動したまま別ターミナルでclientを動かします。URLとportが両方ともhttp://localhost:3000か確認してください。
常にUNAUTHORIZEDになる
clientのheaderがauthorization: "Bearer learning-token"か確認します。Bearerの後ろには半角スペースが必要です。
clientの型が更新されない
clientはimport type { AppRouter } from "./router"でserverのrouter型を参照します。別リポジトリや別言語のclientへそのまま型を共有する用途には向きません。
練習
post.listへpublishedの絞り込みを追加するのではなく、まずpost.byIdを1つ追加してください。入力をz.object({ id: z.number().int().positive() })で検証し、該当する投稿またはnullを返します。
次のステップ
tRPCが向く構成とREST/GraphQLとの違いを整理したい場合は、GraphQLとREST APIの比較を参照してください。
参考リソース
- tRPC 11ドキュメント
- tRPC: Procedures
- tRPC: Authorization
- tRPC: Standalone server adapter
- tRPC: httpBatchLink
- Zod
(最終確認: 2026年7月25日)
← 一覧に戻る