tRPC × Next.js × Prismaで作るモダンなフルスタックアプリ導入

## はじめに
こんにちは、株式会社TechoesのKです（アプリエンジニア4年目）
今回はtRPC × Next.js × Prismaについての記事を書いていこうと思います

フロントエンドとバックエンドの境界がますます曖昧になる中で、**型安全なフルスタック開発**が注目を集めています。特に、TypeScriptをフル活用した開発スタイルは、開発効率や保守性の向上につながるため、多くの開発現場で導入が進んでいます。

本記事では、**Next.js** をベースに、API定義の手間を最小限に抑えつつ、**完全な型安全**を実現する **tRPC**、そしてORMとして人気の高い **Prisma** を組み合わせた、モダンなフルスタックアーキテクチャを紹介します。

RESTやGraphQLとは違い、tRPCではクライアントとサーバー間で**自動的に型を共有**できるため、APIの型定義やバリデーションにかかる労力を大幅に削減できます。また、Prismaを使うことで、型安全なDB操作も簡単に行えるようになります。


これから紹介するステップを通じて、**API定義レスで型安全な開発体験**を一緒に体感していきましょう。

## 1. tRPCとは？

tRPCは、TypeScriptを最大限に活用して**APIの型定義を不要にするフレームワーク**です。RESTやGraphQLのようにリクエスト/レスポンスのスキーマを定義する必要がなく、**TypeScriptの型情報をそのままクライアントに伝播**できます。

特徴的なのは、**クライアントとサーバー間で型が自動で共有される**こと。APIに変更があれば、即座に型エラーとして検知できるため、型のズレによるバグの発生を大幅に防げます。

また、tRPCはNext.jsとの統合が非常に簡単で、API RoutesやApp Routerとも相性が良いため、型安全かつシンプルにAPI開発を行いたい場合に非常に有力な選択肢です。

## 2. Prismaとは？

Prismaは、TypeScriptと抜群の相性を持つ**次世代のORM（Object Relational Mapper）**です。型安全なDB操作が可能で、開発体験が非常に快適です。

tRPCとPrismaを組み合わせることで、**DBからクライアントまでの一貫した型安全性**を保つことができ、以下のようなメリットが得られます：

- Prismaで生成される型がそのままtRPCで利用可能
- サーバー側のバリデーションやスキーマ定義が最小限で済む
- Prisma Clientを通じてDBアクセスし、結果をそのまま返すだけで型が保たれる

つまり、「DB → API → クライアント」の一連のデータフローが、**完全に型で守られた状態**で完結します。

## 3. RESTやGraphQLと比べてどんなメリットがある？

tRPCの最大の魅力は、**APIスキーマの定義が不要**でありながら、**型安全**を実現できる点です。RESTやGraphQLでは以下のような作業が必要になります：

- REST: OpenAPI（Swagger）やZodなどでスキーマ定義・バリデーション
- GraphQL: スキーマ定義（SDL）とリゾルバの実装、型生成

tRPCでは、**ただTypeScriptで関数を書くだけで、クライアントから型安全に呼び出せるAPIが完成**します。

さらに、以下の点でも優れています：

- **開発速度が速い**（API定義や型生成が不要）
- **学習コストが低い**（TypeScriptがわかればOK）
- **保守が楽**（APIの変更が即座に型エラーとして伝播）

もちろん、GraphQLのような柔軟なクエリ構築が必要な大規模システムには向かない場合もありますが、**中小規模のプロジェクトには非常にフィットする構成**です。

## 4. Next.jsとの統合

結論から言うと、**Next.jsとtRPCの統合はとてもシンプル**です。

tRPCは、Next.jsのAPI RoutesやApp Router（`app/` ディレクトリ）と自然に連携できます。特にApp Routerでは、tRPC用のルーターを`app/api/trpc/route.ts`などに定義して、**そのままAPIエンドポイントとして利用可能**です。

主な統合ステップは以下の通りです：

1. `@trpc/server` と `@trpc/client` をインストール
2. サーバー側にルーター（API）を定義
3. クライアント側で `createTRPCProxyClient` を使って呼び出し
4. 型は自動で補完され、API呼び出しが型安全に

また、tRPCは公式に **Next.js向けのアダプター** も用意しているため、認証やミドルウェアとの統合も比較的簡単に行えます。


<br>


それでは早速、tRPC × Next.js × Prismaで実際にアプリを構築してみましょう


## tRPC × Next.js × Prismaでのアプリ開発

<br>

### プロジェクトの初期化
```
npx create-next-app@latest my-trpc-app --typescript
```
```
cd my-trpc-app
```


![スクリーンショット 2025-03-22 12.15.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3714682/f8b954bd-f8bf-49ad-bf37-54734a579f53.png)


まずはNextjsをインストール


<br>

### 必要なパッケージのインストール

tRPC本体 & Next.js用アダプター
```
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
```

zodバリデーション（任意）
```
npm install zod
```

PrismaとDB接続
```
npm install prisma @prisma/client
```

```
npx prisma init
```

<br>

### Prismaのセットアップ

`prisma/schema.prisma`を以下のように編集（Userモデルの追加）

```
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id    Int     @id @default(autoincrement())
  name  String
  email String  @unique
}
```

データベース作成＆マイグレーション
```
npx prisma migrate dev --name init
```

<br>

### tRPCルーターの作成

`app/server/trpc.ts`（tRPCの初期化）
```
import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;

```
`app/server/routers/user.ts`（ユーザー用のAPIルーター）
```
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export const userRouter = router({
  getAll: publicProcedure.query(async () => {
    return prisma.user.findMany();
  }),
  create: publicProcedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => {
      return prisma.user.create({ data: input });
    }),
});

```

`app/server/routers/_app.ts`（ルーターの統合）
```
import { router } from '../trpc';
import { userRouter } from './user';

export const appRouter = router({
  user: userRouter,
});

export type AppRouter = typeof appRouter;

```

<br>

### APIハンドラの作成（App Router対応）
`app/api/trpc/[trpc]/route.ts`
```
import { appRouter } from '@/server/routers/_app';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';

export const dynamic = 'force-dynamic';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({}),
  });

export { handler as GET, handler as POST };
```

<br>

### クライアントのセットアップ

`src/utils/trpc.ts`（クライアント作成）
```
'use client';

import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

```
`src/components/trpcProvider.tsx`にProviderを追加
```
'use client';

import { trpc } from '@/utils/trpc';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { ReactNode, useState } from 'react';

export function TrpcProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [client] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={client} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
```

`app/layout.tsx`で呼び出し

```
import { ReactNode } from 'react';
import { TrpcProvider } from '@/components/trpcProvider';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <TrpcProvider>
          {children}
        </TrpcProvider>
      </body>
    </html>
  );
}
```

<br>

### クライアントで呼び出してみる

`app/page.tsx`にコードを追加
```
'use client';

import { trpc } from '@/utils/trpc';
import { useState } from 'react';

export default function Home() {
  const usersQuery = trpc.user.getAll.useQuery();
  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      usersQuery.refetch(); // 作成後に一覧を更新
    },
  });

  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!name || !email) return;
    createUser.mutate({ name, email });
    setName('');
    setEmail('');
  };

  return (
    <main style={{ padding: '2rem' }}>
      <h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>ユーザー一覧</h1>

      {/* ユーザー表示テーブル */}
      <table border={1} cellPadding={8} cellSpacing={0}>
        <thead>
          <tr>
            <th>ID</th>
            <th>名前</th>
            <th>メールアドレス</th>
          </tr>
        </thead>
        <tbody>
          {usersQuery.data?.map((user) => (
            <tr key={user.id}>
              <td>{user.id}</td>
              <td>{user.name}</td>
              <td>{user.email}</td>
            </tr>
          ))}
          {usersQuery.data?.length === 0 && (
            <tr>
              <td colSpan={3} style={{ textAlign: 'center' }}>
                ユーザーがいません
              </td>
            </tr>
          )}
        </tbody>
      </table>

      {/* ユーザー作成フォーム */}
      <form onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
        <h2>新規ユーザー作成</h2>
        <div style={{ marginBottom: '0.5rem' }}>
          <input
            type="text"
            placeholder="名前"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
        </div>
        <div style={{ marginBottom: '0.5rem' }}>
          <input
            type="email"
            placeholder="メールアドレス"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <button type="submit">作成</button>
      </form>
    </main>
  );
}

```

<br>

ここまでの設定が終わったら`npm run dev`でlocalhost:3000を確認しましょう
ユーザーの作成画面と作成したユーザーの表が画面に表示されているはずです


![スクリーンショット 2025-03-22 13.47.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3714682/7b54d25e-51b8-4407-87a3-9ebed2b669ae.png)


<br>


## 型共有のメリット

では、型共有を確認するため、ここからUserモデルの型を修正してみましょう

### Prisma スキーマを修正
`prisma/schema.prisma`を以下のように編集します
```
model User {
  id    Int     @id @default(autoincrement())
  name  String
  email String  @unique
  age   Int     @default(0)// ← 追加
}
```
マイグレーション
```
npx prisma migrate dev --name add-age-to-user
```

`src/server/router/user.ts`の型を修正
```
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export const userRouter = router({
  getAll: publicProcedure.query(async () => {
    return prisma.user.findMany();
  }),
  create: publicProcedure
    .input(
      z.object({
        name: z.string(),
        email: z.string().email(),
        age: z.number().int().min(0),
      })
    )
    .mutation(async ({ input }) => {
      return prisma.user.create({ data: input });
    }),
});

```
<br>

この時点で、`app/page.tsx`内で型エラーが発生するようになります
バックエンドの型が即座にフロントエンドへ反映されるtRPCの真骨頂ですね

![スクリーンショット 2025-03-22 14.23.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3714682/a9aa2dcf-cd75-4faf-b59e-d692b3924459.png)


`app/page.tsx`を更新
```
'use client';

import { trpc } from '@/utils/trpc';
import { useState } from 'react';

export default function Home() {
  const usersQuery = trpc.user.getAll.useQuery();
  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      usersQuery.refetch(); // 作成後に一覧を更新
    },
  });

  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!name || !email || !age) return;
    createUser.mutate({ name, email, age: Number(age) });
    setName('');
    setEmail('');
    setAge('');
  };

  return (
    <main style={{ padding: '2rem' }}>
      <h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>ユーザー一覧</h1>

      {/* ユーザー表示テーブル */}
      <table border={1} cellPadding={8} cellSpacing={0}>
        <thead>
          <tr>
            <th>ID</th>
            <th>名前</th>
            <th>メールアドレス</th>
            <th>年齢</th>
          </tr>
        </thead>
        <tbody>
          {usersQuery.data?.map((user) => (
            <tr key={user.id}>
              <td>{user.id}</td>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.age}</td>
            </tr>
          ))}
          {usersQuery.data?.length === 0 && (
            <tr>
              <td colSpan={3} style={{ textAlign: 'center' }}>
                ユーザーがいません
              </td>
            </tr>
          )}
        </tbody>
      </table>

      {/* ユーザー作成フォーム */}
      <form onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
        <h2>新規ユーザー作成</h2>
        <div style={{ marginBottom: '0.5rem' }}>
          <input
            type="text"
            placeholder="名前"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
        </div>
        <div style={{ marginBottom: '0.5rem' }}>
          <input
            type="email"
            placeholder="メールアドレス"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
          <input
            type="number"
            placeholder="年齢"
            value={age}
            onChange={(e) => setAge(e.target.value)}
          />
        </div>
        <button type="submit">作成</button>
      </form>
    </main>
  );
}
```

型エラーが出る場合は以下のコマンドを実行してみてください
```
npx prisma generate
```
実行後`npm run dev`しなおすと、画面の表に年齢が表示されます！
<br>

![スクリーンショット 2025-03-22 14.49.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3714682/3b1c3dbf-39f5-4421-9dd4-39cbb024ca57.png)


### おまけ: Prisma Studio
下記コマンドでPrisma Studioを立ち上げることができます
```
npx prisma studio
```
コマンド実行後、http://localhost:5555 でGUIでのDB操作ができます

## まとめ

本記事では、**tRPC × Next.js × Prisma** を使って、型安全かつシンプルなフルスタック開発を体験しました。

tRPC を導入することで、クライアントとサーバー間で **自動的に型を共有**でき、APIの定義やスキーマ生成が不要になります。Prisma と組み合わせることで、データベース操作も型安全に行うことができ、**DB → API → クライアント** という一連の流れを通して一貫した型保証を得られるのが大きなメリットです。

tRPC の最大の魅力は「**型のズレによるバグをゼロに近づける**」ことです。  
機会があればぜひ試してみてください

この記事が皆さんの開発の一助になれば幸いです！