tRPC 11入門 - post.listと認証付きpost.createを作る

中級 | 25分 で読める | 2025.12.02

公式ドキュメント

今回やること

tRPC 11で、投稿を扱う2つのprocedureを作ります。

  • post.list: 認証なしで投稿一覧を読むQuery
  • post.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.listpublishedの絞り込みを追加するのではなく、まずpost.byIdを1つ追加してください。入力をz.object({ id: z.number().int().positive() })で検証し、該当する投稿またはnullを返します。

次のステップ

tRPCが向く構成とREST/GraphQLとの違いを整理したい場合は、GraphQLとREST APIの比較を参照してください。

参考リソース

(最終確認: 2026年7月25日)

← 一覧に戻る
PR
PR
PR
PR