3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

記事の目的

本記事は以下の 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 呼び出しのシーン.
// 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 は通信などの非同期処理の状態管理をしますが、キャッシュ管理等もしてくれます。

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 (受信処理側) の初期化処理を行っています。

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 呼び出しをします。

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

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 を定義しています。

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

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 による、API 入力のバリデーションとコンパイル時点での型エラー検知
  • 呼び出し元コードと API 応答の齟齬を TypeScript 型チェックで検知

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

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} に通信を行います。

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 実装を呼び出します。

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 して使用します。

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 して使用します。

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 に定義します。

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>
  )
}

実行してみる

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

npm run dev

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

http://localhost:3000

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

image.png

参考資料

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?