Next.js (App Router) で tRPC

[tRPC]:https://trpc.io/
[Route Handler]:https://nextjs.org/docs/app/building-your-application/routing/route-handlers
[Next.js (App Router)]:https://nextjs.org/docs/app
[Set up with React Server Components - tRPC公式]:https://trpc.io/docs/client/react/server-components
[tRPCの簡易設定 (AppRouter) - hayato94087さん]:https://zenn.dev/hayato94087/articles/08d63958b57fbe


# 記事の目的

本記事は以下の tRPC 公式のドキュメントに従って、[Next.js (App Router)] に [tRPC] を導入します。

https://trpc.io/docs/client/react/server-components

しかし 2024/10 現在、↑ の通りにコードを書いても何箇所かエラーが出てしまいました。

そこで、エラーを解消し実際に動くまで書き上げたコードが以下のリポジトリです。

https://github.com/megmogmog1965/example-nextjs-trpc

できるかぎり公式に従いつつ、しっかり動くものをコーディングしました。

# tRPC とは？

いわゆる SPA (シングルページアプリケーション) は、ブラウザ上で実行されるフロントの JS コードが、Ajax (fetch, XHR) でバックエンドの API を呼びます。

この際に API は多くのケースで REST (http with json) が採用されます。

FE コードから REST API を呼び出すのは API 仕様の管理・齟齬の問題でそれなりにコストが高く、開発の生産性低下（コードの複雑性、障害の発生）の原因となりがちです。

```mermaid
sequenceDiagram
    participant FE as Frontend
    participant BE as Backend

    FE ->> BE: http (fetch, xhr)
    BE -->> FE: JSON
    Note over FE: API 仕様の認識齟齬でエラー
```

tRPC はそういった問題を解決する目的のフレームワークです。

FE から BE への API 呼び出しを透過的に行い、一見するとただの JavaScript 関数呼び出しのようにできます。

また、API は TypeScript で型化されますので、API 入力・応答の仕様認識齟齬はコンパイル時点で検知できます。

```typescript:tRPC 呼び出しのシーン.
// 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")

```mermaid
sequenceDiagram
    box Client-Side
        participant FE as FE ("use client")
        participant TRPC as @trpc/react-query
    end

    box Server-Side
        participant BE as BE (Route Handler)
        participant Router as @trpc/server/adapters/fetch
        participant API as BE (API 実装)
    end

    FE ->> TRPC: tRPC Client 呼び出し
    note over TRPC, BE: `@trpc/react-query` による http fetch 実装
    TRPC ->> BE: http req (fetch)
    BE ->> Router: function call
    Router ->> API: function call
    API -->> Router: ...
    Router -->> BE: ...
    BE -->> TRPC: http res (JSON)
    TRPC -->> FE: tRPC 応答 (javascript object) への変換
```

## SSR (React Server Component)

```mermaid
sequenceDiagram
    box Server-Side
        participant FE as FE (RSC)
        participant TRPC as @trpc/react-query/rsc
        participant Router as @trpc/server
        participant API as BE (API 実装)
    end

    FE ->> TRPC: tRPC Client 呼び出し
    note over TRPC, Router: http を介さない、プロセス内での結合
    TRPC ->> Router: function call
    Router ->> API: function call
    API -->> Router: ...
    Router -->> TRPC: ...
    TRPC -->> FE: tRPC 応答 (javascript object) への変換
```


# 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 |

# 構築手順

https://github.com/megmogmog1965/example-nextjs-trpc

## 1. Next.js 初期プロジェクト構築

おなじみの Next.js チュートリアルから、以下のコマンドを実行して初期のプロジェクトを生成します。

```bash
npx create-next-app@next-14-1
```

:::note warn
本来は `npx create-next-app@latest` とすべきですが、10/30 現在、次の手順で `npm error ERESOLVE unable to resolve dependency tree` が発生したので今回は Next.js 14 に固定しました。
:::

次に以下の NPM Modules をインストールします。

```bash
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 は通信などの非同期処理の状態管理をしますが、キャッシュ管理等もしてくれます。

```typescript:trpc/query-client.ts
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 (受信処理側) の初期化処理を行っています。

```typescript:trpc/init.ts
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 呼び出しをします。

:::note
tRPC API 呼び出しは http (fetch) を介して透過的に行われます。
:::

```tsx:trpc/client.tsx
'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 を定義しています。

:::note
`trpc/client.ts` とは異なり http は介さず、プロセス内で完結して応答を返します。
:::

```typescript:trpc/server.ts
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](https://github.com/colinhacks/zod) による、API 入力のバリデーションとコンパイル時点での型エラー検知
* 呼び出し元コードと API 応答の齟齬を TypeScript 型チェックで検知

:::note warn
本サンプルコードでは、API の処理を直接コーディングしちゃってます。処理の実態を別ファイルにしてこのディレクトリ配下で管理すべきかなと思います。
:::

```typescript:trpc/routers/_app.ts
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}` に通信を行います。

```tsx:app/layout.tsx
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 実装を呼び出します。

```typescript:app/api/trpc/[trpc]/route.ts
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 して使用します。

```tsx:components/greeting-client.tsx
'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 して使用します。


```tsx:components/greeting-server.tsx
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` に定義します。

```tsx:app/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>
  )
}
```

# 実行してみる

開発サーバーを起動します。

```bash
npm run dev
```

ブラウザでアクセスしてみましょう。

http://localhost:3000

<img width="400" alt="http://localhost:3000" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/aada21cc-2570-0329-53ec-662320e8bb2f.png">

また、Chrome dev console で見てみると Client Component (SPA) からの tRPC リクエストは、実際には１つにまとめられた http 通信として行われているのがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/79a3b0d5-9237-8564-9b11-bc8eacaf9ee7.png)

# 参考資料

* 原則的に [Set up with React Server Components - tRPC公式] に従うことを正としています
* ただし多々動かない点がありましたので、特に [tRPCの簡易設定 (AppRouter) - hayato94087さん] の記事をかなり参考にさせて頂きました。ありがとうございます🚀