メインコンテンツへスキップ

tRPCでServer Actionsを使う

· 1分で読める
Julius Marminge
tRPC Core Team Member
非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

tRPC v10で導入されたプロシージャ作成のビルダーパターンはコミュニティから高く評価され、多くのライブラリが同様のパターンを採用しています。このパターンの人気が高まっている証拠に、tRPC like XYZという用語まで生まれました。実際、先日tRPCと似たAPIでCLIアプリケーションを作る方法はないかという投稿も見かけました。ちなみに、tRPCを直接使って実現する方法もありますが、今日のテーマはそれではなく、Next.jsのServer ActionsをtRPCで活用する方法についてです。

Server Actionとは?

最新のReactNext.js機能を追っていない方のために説明すると、Server Actionsを使えばサーバー上で実行される通常の関数をクライアント側からインポートし、あたかもローカル関数のように呼び出せます。これはtRPCと似ていると思うかもしれませんが、その通りです。Dan Abramovによれば、Server Actionsとは「バンドラー機能としてのtRPC」なのです:

この指摘はまったく正確で、Server ActionsはtRPCと似ています。結局のところ両者ともRPCの一種です。どちらもバックエンドに関数を定義し、フロントエンドからネットワーク層を抽象化した状態で完全な型安全を保ちながら呼び出せます。

ではtRPCの役割はどこにあるのでしょうか?なぜ両方必要なのか?Server Actionsはプリミティブであり、すべてのプリミティブと同様に基本的な機能に留まるため、API構築において重要な側面が欠けています。ネットワーク経由で公開されるAPIエンドポイントには、悪用を防ぐためのリクエスト検証と認可が不可欠です。前述のようにtRPCのAPIは高く評価されているので、tRPCを使ってServer Actionsを定義し、入力検証、ミドルウェアによる認証認可、出力検証、データ変換などtRPC組み込みの優れた機能を活用できたら素晴らしいと思いませんか?私もそう考えます。さっそく詳しく見ていきましょう。

tRPCでServer Actionsを定義する

注記

前提条件: Server Actionsを使用するにはNext.jsのApp Routerが必要です。また、これから使用するtRPC機能はすべてtRPC v11で利用可能なため、必ずtRPCのベータリリースチャンネルを使用してください:

npm install @trpc/server

まずtRPCを初期化し、基本となるServer Actionsプロシージャを定義します。ここではプロシージャビルダーのexperimental_callerメソッドを使用します。これは関数が呼び出された際の実行方法をカスタマイズできる新機能です。また、Next.js互換にするためにexperimental_nextAppDirCallerアダプターも使用します。このアダプターは、クライアント側でuseActionStateでラップされたServer Actionsの呼び出しシグネチャが変化するケースを処理します。

通常のルーター(例:user.byId)のようなパスがないため、メタデータとしてspanプロパティも使用します。このspanプロパティは、ロギングやオブザーバビリティなどでプロシージャを区別するのに活用できます。

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
);
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
);

次にコンテキストを追加します。通常のHTTPアダプター経由でルーターをホストしないため、アダプターのcreateContextメソッドによるコンテキスト注入は行われません。代わりにミドルウェアを使用してコンテキストを注入します。この例ではセッションから現在のユーザーを取得し、コンテキストに注入してみましょう。

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
});
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
});

最後に、未認証ユーザーからアクションを保護する protectedAction プロシージャを作成します。既存のミドルウェアがある場合はそれを使えますが、ここでは例としてインラインで定義します。

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
 
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // <-- ensures type is non-nullable
(property) user: User
},
});
});
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
 
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // <-- ensures type is non-nullable
(property) user: User
},
});
});

では実際のサーバーアクションを書きましょう。_actions.ts ファイルを作成し、"use server" ディレクティブを付与してアクションを定義します。

app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = protectedAction
.input(
z.object({
title: z.string(),
}),
)
.mutation(async (opts) => {
// Do something with the input
});
 
// Since we're using the `experimental_caller`,
// our procedure is now just an ordinary function:
createPost;
const createPost: (input: { title: string; }) => Promise<void>
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = protectedAction
.input(
z.object({
title: z.string(),
}),
)
.mutation(async (opts) => {
// Do something with the input
});
 
// Since we're using the `experimental_caller`,
// our procedure is now just an ordinary function:
createPost;
const createPost: (input: { title: string; }) => Promise<void>

未認証ユーザーからの保護とSQLインジェクションなどの攻撃を防ぐ入力検証を備えたサーバーアクションを、これほど簡単に定義できるのです。クライアント側でこの関数をインポートして呼び出してみましょう。

app/post-form.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await createPost({ title });
}}
>
<input type="text" name="title" />
<button type="submit">Create Post</button>
</form>
);
}
app/post-form.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await createPost({ title });
}}
>
<input type="text" name="title" />
<button type="submit">Create Post</button>
</form>
);
}

さらに発展させる

tRPCビルダーとその再利用可能なプロシージャを定義する合成可能な方法を活用すれば、より複雑なサーバーアクションも簡単に構築できます。以下にいくつかの例を示します:

オブザーバビリティ

@baselime/node-opentelemtry のtrpcプラグインを使えば、数行のコードでオブザーバビリティを追加できます:

diff
--- server/trpc.ts
+++ server/trpc.ts
+ import { tracing } from '@baselime/node-opentelemetry/trpc';
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: (meta: Meta) => meta.span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export const createPost = protectedAction
+ .meta({ span: 'create-post' })
.input(
z.object({
title: z.string(),
}),
)
.mutation(async (opts) => {
// Do something with the input
});
diff
--- server/trpc.ts
+++ server/trpc.ts
+ import { tracing } from '@baselime/node-opentelemetry/trpc';
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: (meta: Meta) => meta.span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export const createPost = protectedAction
+ .meta({ span: 'create-post' })
.input(
z.object({
title: z.string(),
}),
)
.mutation(async (opts) => {
// Do something with the input
});

詳細は Baselime tRPC統合ドキュメント を参照してください。同様のパターンは、使用しているオブザーバビリティプラットフォームでも動作するはずです。

レート制限

Unkeyのようなサービスを使えば、サーバーアクションにレート制限をかけられます。ユーザーごとのリクエスト数を制限するUnkeyを使った保護付きサーバーアクションの例です:

server/trpc.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
server/trpc.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = rateLimitedAction
.input(
z.object({
postId: z.string(),
content: z.string(),
}),
)
.mutation(async (opts) => {
console.log(
`${opts.ctx.user.name} commented on ${opts.input.postId} saying ${opts.input.content}`,
);
});
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = rateLimitedAction
.input(
z.object({
postId: z.string(),
content: z.string(),
}),
)
.mutation(async (opts) => {
console.log(
`${opts.ctx.user.name} commented on ${opts.input.postId} saying ${opts.input.content}`,
);
});

tRPCプロシージャのレート制限については Unkeyチームのこちらの記事 で詳しく解説されています。

可能性は無限大です。皆さんが現在tRPCアプリケーションで使用している便利なユーティリティミドルウェアも既にたくさんあるでしょう。もしなければ、npm install で導入できるものが見つかるかもしれません!

まとめ

サーバーアクションは決して万能薬ではありません。より動的なデータが必要な場面では、クライアントサイドのReact Queryキャッシュにデータを保持し、useMutation を使った変更を行う方が適切かもしれません。それはまったく合理的な判断です。これらの新しいプリミティブは段階的に導入できるため、既存のtRPC APIから個々のプロシージャを、適切な場所でサーバーアクションに移行できます。API全体を書き直す必要はありません。

tRPCを使ってサーバーアクションを定義することで、現在使用しているロジックの多くを共有し、変更をサーバーアクションとして公開するか、より伝統的なミューテーションとして公開するかを選択できます。開発者である皆さんが、アプリケーションに最適なパターンを選ぶ権限を持っているのです。現在tRPCを使用していない場合でも、型安全で入力検証付きのサーバーアクションを定義できるパッケージ(next-safe-actionzsa など)が存在し、これらも検討する価値があります。

実際にこの仕組みを採用したアプリを見たい場合は、私が最近作成した Trellix tRPC をチェックしてみてください。これらの新しいプリミティブを活用したアプリケーションです。

ご意見をお聞かせください

いかがでしたか? Githubディスカッション でぜひご意見をお聞かせいただき、これらのプリミティブを安定版に仕上げるための改善にご協力ください。

特にエラーハンドリングに関してはまだ作業が必要です。Next.jsはエラーを返すことを推奨しており、これを可能な限り型安全にしたいと考えています。Alexによる こちらのWIPプルリクエスト で初期段階の作業を確認できます。

それでは次回まで、コーディングを楽しんでください!