最終更新:
tRPC v11実践: Server Functionsとの統合による型安全なフルスタック開発
tRPC v11実践: Server Functionsとの統合による型安全なフルスタック開発
tRPC v11では、React Server Components(RSC)やNext.js App Routerとのシームレスな統合を実現する「Server Functions」が導入されました。この記事では、従来のRPC方式とServer Functionsの違い、実装パターン、パフォーマンス最適化まで徹底解説します。
tRPC v11の新機能: Server Functions
従来のtRPCとの違い
従来のtRPCは、HTTPエンドポイントを経由してクライアント・サーバー間でRPCコールを実行していました。tRPC v11のServer Functionsは、React Server ActionsやNext.js Server Actionsと同様のパラダイムを採用し、より直感的なサーバーサイドロジック呼び出しを実現します。
// 従来のtRPC (v10)
const users = await trpc.user.list.query() // HTTPリクエスト経由
// tRPC v11 Server Functions
const users = await getUserList() // 直接サーバー関数を呼び出し
Server Functionsの利点
- ゼロコストRPC - バンドルサイズの削減(クライアント側コード不要)
- サーバーコンポーネントとの完全統合 - async/awaitでシームレスに利用
- 型安全性の維持 - TypeScriptの型推論がそのまま機能
- ストリーミング対応 - React 19のSuspenseやStreaming SSR対応
- コロケーション - サーバーロジックとUIコンポーネントを近接配置
セットアップ
インストール
npm install @trpc/server@next @trpc/client@next @trpc/react-query@next
npm install @trpc/next@next @tanstack/react-query zod
プロジェクト構造
src/
├── app/
│ ├── (routes)/
│ │ ├── users/
│ │ │ ├── page.tsx
│ │ │ └── actions.ts # Server Functions
│ │ └── posts/
│ │ ├── page.tsx
│ │ └── actions.ts
│ ├── api/trpc/[trpc]/route.ts # 従来のRPCエンドポイント(オプション)
│ └── layout.tsx
├── server/
│ ├── routers/
│ │ ├── user.ts
│ │ └── post.ts
│ ├── functions/ # Server Functions定義
│ │ ├── user.ts
│ │ └── post.ts
│ ├── trpc.ts
│ └── index.ts
└── lib/
└── trpc.ts
サーバー設定
// src/server/trpc.ts
import { initTRPC } from '@trpc/server'
import { cache } from 'react'
import { cookies, headers } from 'next/headers'
// Server Functions用のコンテキスト作成(React cacheでメモ化)
export const createContext = cache(async () => {
const cookieStore = await cookies()
const headersList = await headers()
return {
cookies: cookieStore,
headers: headersList,
// 認証情報など
userId: cookieStore.get('userId')?.value,
}
})
export type Context = Awaited<ReturnType<typeof createContext>>
export const t = initTRPC.context<Context>().create({
transformer: {
serialize: (object) => JSON.stringify(object),
deserialize: (object) => JSON.parse(object),
},
})
export const router = t.router
export const publicProcedure = t.procedure
export const createCallerFactory = t.createCallerFactory
Server Functions定義
// src/server/functions/user.ts
import { z } from 'zod'
import { createContext, createCallerFactory, router, publicProcedure } from '../trpc'
import { db } from '@/lib/db'
// ルーター定義
export const userRouter = router({
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional(),
})
)
.query(async ({ input }) => {
const users = await db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
})
let nextCursor: string | undefined
if (users.length > input.limit) {
const nextItem = users.pop()
nextCursor = nextItem!.id
}
return {
users,
nextCursor,
}
}),
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({
where: { id: input.id },
})
if (!user) {
throw new Error('User not found')
}
return user
}),
create: publicProcedure
.input(
z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
})
)
.mutation(async ({ input }) => {
return await db.user.create({
data: input,
})
}),
})
// Server Function用のcaller作成
const createCaller = createCallerFactory(userRouter)
// Reactコンポーネントから直接呼び出せるServer Functions
export async function getUserList(input?: { limit?: number; cursor?: string }) {
const ctx = await createContext()
const caller = createCaller(ctx)
return caller.list(input ?? {})
}
export async function getUserById(id: string) {
const ctx = await createContext()
const caller = createCaller(ctx)
return caller.getById({ id })
}
export async function createUser(input: { name: string; email: string }) {
'use server'
const ctx = await createContext()
const caller = createCaller(ctx)
return caller.create(input)
}
React Server Componentsでの利用
基本的な使い方
// src/app/users/page.tsx
import { getUserList } from '@/server/functions/user'
import { Suspense } from 'react'
async function UserList() {
const { users } = await getUserList({ limit: 20 })
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
)
}
export default function UsersPage() {
return (
<div>
<h1>Users</h1>
<Suspense fallback={<div>Loading users...</div>}>
<UserList />
</Suspense>
</div>
)
}
パラレルデータフェッチング
// src/app/dashboard/page.tsx
import { getUserStats } from '@/server/functions/user'
import { getPostStats } from '@/server/functions/post'
import { getAnalytics } from '@/server/functions/analytics'
export default async function DashboardPage() {
// 並列実行でパフォーマンス最適化
const [userStats, postStats, analytics] = await Promise.all([
getUserStats(),
getPostStats(),
getAnalytics(),
])
return (
<div>
<StatCard title="Users" data={userStats} />
<StatCard title="Posts" data={postStats} />
<AnalyticsChart data={analytics} />
</div>
)
}
ストリーミングSSR
// src/app/posts/page.tsx
import { Suspense } from 'react'
import { getRecentPosts, getTrendingPosts } from '@/server/functions/post'
async function RecentPosts() {
const posts = await getRecentPosts()
return <PostList posts={posts} />
}
async function TrendingPosts() {
// 重い処理でもストリーミングで段階的レンダリング
const posts = await getTrendingPosts()
return <PostList posts={posts} />
}
export default function PostsPage() {
return (
<div>
<section>
<h2>Recent Posts</h2>
<Suspense fallback={<Skeleton />}>
<RecentPosts />
</Suspense>
</section>
<section>
<h2>Trending Posts</h2>
<Suspense fallback={<Skeleton />}>
<TrendingPosts />
</Suspense>
</section>
</div>
)
}
Client Componentsでの利用(Server Actions)
フォーム送信
// src/app/users/new/page.tsx
'use client'
import { createUser } from '@/server/functions/user'
import { useActionState } from 'react'
export default function NewUserPage() {
const [state, formAction, isPending] = useActionState(
async (prevState: any, formData: FormData) => {
try {
const result = await createUser({
name: formData.get('name') as string,
email: formData.get('email') as string,
})
return { success: true, user: result }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
},
{ success: false }
)
return (
<form action={formAction}>
<input name="name" required placeholder="Name" />
<input name="email" type="email" required placeholder="Email" />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create User'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">User created!</p>}
</form>
)
}
Optimistic Updates
// src/app/posts/[id]/like-button.tsx
'use client'
import { toggleLike } from '@/server/functions/post'
import { useOptimistic, useTransition } from 'react'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [isPending, startTransition] = useTransition()
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(currentLikes, amount: number) => currentLikes + amount
)
const handleLike = () => {
startTransition(async () => {
addOptimisticLike(1) // 即座にUIを更新
await toggleLike({ postId })
})
}
return (
<button onClick={handleLike} disabled={isPending}>
❤️ {optimisticLikes} Likes
</button>
)
}
認証とミドルウェア
コンテキストベース認証
// src/server/trpc.ts
import { TRPCError } from '@trpc/server'
import { getServerSession } from '@/lib/auth'
export const createContext = cache(async () => {
const session = await getServerSession()
return {
session,
userId: session?.user?.id,
}
})
// 認証ミドルウェア
const isAuthenticated = t.middleware(async ({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
})
}
return next({
ctx: {
...ctx,
userId: ctx.userId, // 型を確定
},
})
})
export const protectedProcedure = t.procedure.use(isAuthenticated)
認証が必要なServer Function
// src/server/functions/post.ts
import { protectedProcedure, router, createCallerFactory, createContext } from '../trpc'
import { z } from 'zod'
const postRouter = router({
createDraft: protectedProcedure
.input(
z.object({
title: z.string().min(1),
content: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
return await db.post.create({
data: {
...input,
authorId: ctx.userId, // 型安全
published: false,
},
})
}),
})
const createCaller = createCallerFactory(postRouter)
export async function createDraft(input: { title: string; content: string }) {
'use server'
const ctx = await createContext()
const caller = createCaller(ctx)
return caller.createDraft(input)
}
ハイブリッド構成: RPCとServer Functionsの併用
使い分けのガイドライン
// Server Functions: サーバーコンポーネント・Server Actionsで使用
export async function getUserProfile(id: string) {
const ctx = await createContext()
const caller = createCaller(ctx)
return caller.getById({ id })
}
// 従来のRPC: クライアントコンポーネントでのリアルタイムデータ取得
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
})
export { handler as GET, handler as POST }
クライアント側でのRPC利用
// src/app/users/[id]/real-time-activity.tsx
'use client'
import { trpc } from '@/lib/trpc'
export function RealTimeActivity({ userId }: { userId: string }) {
// リアルタイムデータはRPCで取得
const { data: activity } = trpc.user.getActivity.useQuery(
{ userId },
{
refetchInterval: 5000, // 5秒ごとに更新
}
)
return (
<div>
<h3>Recent Activity</h3>
{activity?.map((item) => (
<ActivityItem key={item.id} item={item} />
))}
</div>
)
}
パフォーマンス最適化
React Cache活用
// src/server/functions/user.ts
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
// リクエスト単位でのキャッシュ
export const getUserById = cache(async (id: string) => {
const ctx = await createContext()
const caller = createCaller(ctx)
return caller.getById({ id })
})
// Next.js Data Cacheでの永続化
export const getCachedUserStats = unstable_cache(
async () => {
const ctx = await createContext()
const caller = createCaller(ctx)
return caller.getStats()
},
['user-stats'],
{
revalidate: 3600, // 1時間キャッシュ
tags: ['user-stats'],
}
)
Prefetchingとパラレルフェッチ
// src/app/posts/[id]/page.tsx
import { getPostById, getRelatedPosts } from '@/server/functions/post'
export async function generateStaticParams() {
// ビルド時に静的生成するパスを指定
const posts = await getAllPostIds()
return posts.map((id) => ({ id }))
}
export default async function PostPage({ params }: { params: { id: string } }) {
// 並列取得
const [post, relatedPosts] = await Promise.all([
getPostById(params.id),
getRelatedPosts(params.id),
])
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
<aside>
<h2>Related Posts</h2>
<RelatedPostList posts={relatedPosts} />
</aside>
</article>
)
}
Partial Prerendering (PPR)
// src/app/dashboard/page.tsx
export const experimental_ppr = true
export default async function DashboardPage() {
// 静的部分: ビルド時に生成
const staticData = await getStaticDashboardData()
return (
<div>
<StaticHeader data={staticData} />
{/* 動的部分: リクエスト時に生成 */}
<Suspense fallback={<Skeleton />}>
<DynamicUserStats />
</Suspense>
</div>
)
}
async function DynamicUserStats() {
const stats = await getUserStats() // 動的データ取得
return <UserStatsCard stats={stats} />
}
エラーハンドリング
Server Functionのエラー処理
// src/server/functions/post.ts
import { TRPCError } from '@trpc/server'
export async function publishPost(id: string) {
'use server'
try {
const ctx = await createContext()
const caller = createCaller(ctx)
return await caller.publish({ id })
} catch (error) {
if (error instanceof TRPCError) {
// tRPCエラーをReact Server Action形式に変換
throw new Error(error.message)
}
throw error
}
}
クライアント側でのエラーハンドリング
// src/app/posts/[id]/publish-button.tsx
'use client'
import { publishPost } from '@/server/functions/post'
import { useTransition } from 'react'
export function PublishButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const handlePublish = () => {
setError(null)
startTransition(async () => {
try {
await publishPost(postId)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to publish')
}
})
}
return (
<>
<button onClick={handlePublish} disabled={isPending}>
{isPending ? 'Publishing...' : 'Publish'}
</button>
{error && <p className="error">{error}</p>}
</>
)
}
まとめ
tRPC v11のServer Functionsは、React Server ComponentsとServer Actionsのパラダイムに完全適合し、以下の利点を提供します。
主な利点
- バンドルサイズの削減 - クライアント側のRPCコードが不要
- シームレスな統合 - RSC/Server Actionsとネイティブに統合
- 型安全性の維持 - TypeScriptの型推論がそのまま機能
- 柔軟性 - RPCとServer Functionsのハイブリッド構成が可能
使い分けの推奨
- Server Functions: 初期ページロード、フォーム送信、サーバーコンポーネント
- 従来のRPC: リアルタイムデータ、クライアント側のインタラクティブ機能、WebSocket
tRPC v11は、Next.js App Routerの能力を最大限活用し、モダンなフルスタック開発を実現する強力なツールです。