記事の目的
本記事は以下の tRPC 公式のドキュメントに従って、Next.js (App Router) に tRPC を導入します。
しかし 2024/10 現在、↑ の通りにコードを書いても何箇所かエラーが出てしまいました。
そこで、エラーを解消し実際に動くまで書き上げたコードが以下のリポジトリです。
できるかぎり公式に従いつつ、しっかり動くものをコーディングしました。
tRPC とは?
いわゆる SPA (シングルページアプリケーション) は、ブラウザ上で実行されるフロントの JS コードが、Ajax (fetch, XHR) でバックエンドの API を呼びます。
この際に API は多くのケースで REST (http with json) が採用されます。
FE コードから REST API を呼び出すのは API 仕様の管理・齟齬の問題でそれなりにコストが高く、開発の生産性低下(コードの複雑性、障害の発生)の原因となりがちです。
tRPC はそういった問題を解決する目的のフレームワークです。
FE から BE への API 呼び出しを透過的に行い、一見するとただの JavaScript 関数呼び出しのようにできます。
また、API は TypeScript で型化されますので、API 入力・応答の仕様認識齟齬はコンパイル時点で検知できます。
// tRPC API の呼び出し. (入力は TypeScript 型化されている)
const user = trpc.user_api.useQuery({ email: 'yusuke@example.com' })
// TypeScript 型化された応答.
user.data?.name // > 'yusuke' !
本記事の前提となるアーキテクチャ
本記事では、フロントコードに加え API も Next.js (同一プロセス) に閉じてモノレポ実装をします。
API は tRPC と Next.js - Route Handler で実装します。
SPA ("use client")
SSR (React Server Component)
Directory layout & Files
最終的なディレクトリ構造は以下の通りです。追加・変更ファイルのみ記載しました。
.
├── app
│ ├── api
│ │ └── trpc
│ │ └── [trpc]
│ │ └── route.ts
│ │
│ ├── layout.tsx
│ └── page.tsx
│
├── components
│ ├── greeting-client.tsx
│ └── greeting-server.tsx
│
├── trpc
│ ├── routers
│ │ └── _app.ts
│ │
│ ├── init.ts
│ ├── query-client.ts
│ ├── client.tsx
│ └── server.tsx
│
└── package.json
それぞれのファイルの説明です。
Filename | Description |
---|---|
app/api/trpc/[trpc]/route.ts |
Client Component (SPA) コードからの全ての tRPC API 呼び出しをここでまとめて受信します。 |
app/layout.tsx |
Client Component (SPA) コードで tRPC Client を利用するには、ここで tRPC の Provider Component でラップします。 |
app/page.tsx |
標準の page.tsx です。サンプルコード。 |
components/*.tsx |
サンプルコードとして use client した Client Component (SPA) と Server Component (SSR) を用意しました。 |
trpc/routers/_app.tsx |
tRPC API の API Route 定義ファイルです。API とその型仕様をここに一覧します。 |
trpc/init.tsx |
tRPC Server (受信処理側) の初期化処理がここに書かれます。 |
trpc/query-client.tsx |
React Query の定義ファイルです。 |
trpc/client.tsx |
Client Component (SPA) コード向けの tRPC Client |
trpc/server.tsx |
Server Component (SSR) コード向けの tRPC Client |
構築手順
1. Next.js 初期プロジェクト構築
おなじみの Next.js チュートリアルから、以下のコマンドを実行して初期のプロジェクトを生成します。
npx create-next-app@next-14-1
本来は npx create-next-app@latest
とすべきですが、10/30 現在、次の手順で npm error ERESOLVE unable to resolve dependency tree
が発生したので今回は Next.js 14 に固定しました。
次に以下の NPM Modules をインストールします。
npm install @trpc/server@next @trpc/client@next @trpc/react-query@next @tanstack/react-query@latest zod client-only server-only
2. tRPC の初期化
trpc/query-client.ts
tRPC 呼び出しの際に使用する React Query の生成をする function 定義です。
React Query は通信などの非同期処理の状態管理をしますが、キャッシュ管理等もしてくれます。
import {
defaultShouldDehydrateQuery,
QueryClient,
} from '@tanstack/react-query'
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
},
},
})
}
trpc/init.ts
ここでは tRPC Server (受信処理側) の初期化処理を行っています。
import { initTRPC } from '@trpc/server'
import { cache } from 'react'
/**
* tRPC 応答時に参照できるコンテキストの生成関数.
*/
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context
*/
return { userId: 'user_123' } // (コンテキストの例) Authorization ヘッダをパースしてユーザーIDを取り出す, など...
})
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.context<typeof createTRPCContext>().create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
})
// Base router and procedure helpers
export const createTRPCRouter = t.router
export const createCallerFactory = t.createCallerFactory
export const baseProcedure = t.procedure
trpc/client.tsx
Client Component 向けの tRPC Client を定義しています。
"use client"
宣言をした Client Component (SPA) で、これを import して tRPC API 呼び出しをします。
tRPC API 呼び出しは http (fetch) を介して透過的に行われます。
'use client'
import React, { useState } from 'react'
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchLink } from '@trpc/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { type AppRouter } from '@/trpc/routers/_app'
import { makeQueryClient } from '@/trpc/query-client'
/**
* Client-Side (SPA) 向けの tRPC client.
*/
export const trpc = createTRPCReact<AppRouter>({})
// tRPC client のシングルトン共有.
let clientQueryClientSingleton: QueryClient | undefined = undefined
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient()
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= makeQueryClient())
}
/**
* TRPC Provider (wrapper component) for layout.tsx.
*/
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}
trpc/server.ts
Server-Side (SSR, React Server Component) 向けの tRPC Client を定義しています。
trpc/client.ts
とは異なり http は介さず、プロセス内で完結して応答を返します。
import 'server-only' // <-- ensure this file cannot be imported from the client
import { createHydrationHelpers } from '@trpc/react-query/rsc'
import { cache } from 'react'
import { createCallerFactory, createTRPCContext } from '@/trpc/init'
import { makeQueryClient } from './query-client'
import { appRouter } from '@/trpc/routers/_app'
export const getQueryClient = cache(makeQueryClient)
const caller = createCallerFactory(appRouter)(createTRPCContext)
/**
* Server-Side 向けの tRPC client.
*/
export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient,
)
3. tRPC API ルート定義
trpc/routers/_app.ts
tRPC による API (一覧) を定義します。
従来の REST API (http) では難しかった API 仕様 (入出力) 違反のエラー検知や、http 処理によるコードのオーバーヘッドの低減等が見込めます。
- TypeScript & zod による、API 入力のバリデーションとコンパイル時点での型エラー検知
- 呼び出し元コードと API 応答の齟齬を TypeScript 型チェックで検知
本サンプルコードでは、API の処理を直接コーディングしちゃってます。処理の実態を別ファイルにしてこのディレクトリ配下で管理すべきかなと思います。
import { z } from 'zod'
import { baseProcedure, createTRPCRouter } from '@/trpc/init'
/**
* API Router 定義.
*
* 本サンプルコードでは API 処理の実態を直接記述しているが、本来は別のファイル, ディレクトリに構造化して管理すべき.
*/
export const appRouter = createTRPCRouter({
hello: baseProcedure
.input(
// zod による入力バリデーション.
z.object({
text: z.string(),
}),
)
.query((opts) => {
// tRPC API 応答の実装.
return {
// コンテキスト `ctx` と入力 `input` を参照できる.
greeting: `hello ${opts.ctx.userId}, ${opts.input.text}`,
}
}),
})
// export type definition of API
export type AppRouter = typeof appRouter
4. Client Component (SPA) で tRPC Client を使えるようにする
app/layout.tsx
Client Component (SPA) では、もう一段階ほど前準備が必要です。
trpc/client.ts
で定義した TRPCProvider
Component で、ページ中の全ての子 Component をラップします。
Client Component (SPA) 内で tRPC Client 呼び出しを行うと、実際には TRPCProvider
が http 経由で http(s)://{hostname}/api/trpc/{api_name}
に通信を行います。
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { TRPCProvider } from '@/trpc/client'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={inter.className}>
{/* wrap root layout with TRPCProvider. */}
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
)
}
app/api/trpc/[trpc]/route.ts
前述の TRPCProvider
が http 通信する先が、この Route handler になります。
trpc/routers/_app.ts
で定義した全ての tRPC API を一貫してこの Route handler が受け付けし、該当の API 実装を呼び出します。
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/trpc/routers/_app'
import { createTRPCContext } from '@/trpc/init'
/**
* Client-Side からの tRPC 問い合わせは, fetch, XHR を介して http(s) 送信され、全てこの Route Handler でまとめて処理される.
*/
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
})
export { handler as GET, handler as POST }
5. 実際に tRPC Client 呼び出しをしてみる
ここからは構築した Next.js / tRPC の仕組みを使って、実際に tRPC API の呼び出しを行ってみます。
サンプルコードは ① Client Component (SPA) と ② SSR (React Server Component) をそれぞれ試します。
① components/greeting-client.tsx
Client Component (SPA) の例です。
'use client'
したコードでは、@/trpc/client
の方から tRPC Client を import して使用します。
'use client'
import { trpc } from '@/trpc/client'
export function GreetingClient({ name }: { name: string }) {
// tRPC 問い合わせ. Client-Side から fetch を介して HTTP 送信される.
const greeting = trpc.hello.useQuery({ text: name })
return (
<div className="bg-red-100 border-2 border-red-500 rounded-md m-2 p-5 space-y-2">
<div className="text-red-500 font-bold">Client Component</div>
<div>{JSON.stringify(greeting.data)}</div>
</div>
)
}
② components/greeting-server.tsx
SSR (React Server Component) の例です。
RSC コードでは、@/trpc/server
の方から tRPC Client を import して使用します。
import { trpc } from '@/trpc/server'
export async function GreetingServer({ name }: { name: string }) {
// tRPC 問い合わせ. Server-Side なので直接接続されている.
const greeting = await trpc.hello({ text: name })
return (
<div className="bg-blue-100 border-2 border-blue-500 rounded-md m-2 p-5 space-y-1">
<div className="text-blue-500 font-bold">Server Component</div>
<div>{JSON.stringify(greeting)}</div>
</div>
)
}
app/page.tsx
最後にこれらの Component を page.tsx
に定義します。
import { GreetingClient } from '@/components/greeting-client'
import { GreetingServer } from '@/components/greeting-server'
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
{/* Client Component からの tRPC 問い合わせ. */}
<GreetingClient name="Client 1" />
<GreetingClient name="Client 2" />
{/* SSR Component からの tRPC 問い合わせ. */}
<GreetingServer name="Server 1"/>
</main>
)
}
実行してみる
開発サーバーを起動します。
npm run dev
ブラウザでアクセスしてみましょう。
また、Chrome dev console で見てみると Client Component (SPA) からの tRPC リクエストは、実際には1つにまとめられた http 通信として行われているのがわかります。
参考資料
- 原則的に Set up with React Server Components - tRPC公式 に従うことを正としています
- ただし多々動かない点がありましたので、特に tRPCの簡易設定 (AppRouter) - hayato94087さん の記事をかなり参考にさせて頂きました。ありがとうございます🚀