1
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 × Prismaで作るモダンなフルスタックアプリ導入

Last updated at Posted at 2025-04-11

はじめに

こんにちは、株式会社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向けのアダプター も用意しているため、認証やミドルウェアとの統合も比較的簡単に行えます。


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

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


プロジェクトの初期化

npx create-next-app@latest my-trpc-app --typescript
cd my-trpc-app

スクリーンショット 2025-03-22 12.15.55.png

まずはNextjsをインストール


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

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

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

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;


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

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

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

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

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


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

スクリーンショット 2025-03-22 13.47.40.png


型共有のメリット

では、型共有を確認するため、ここから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 });
    }),
});


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

スクリーンショット 2025-03-22 14.23.38.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しなおすと、画面の表に年齢が表示されます!

スクリーンショット 2025-03-22 14.49.20.png

おまけ: Prisma Studio

下記コマンドでPrisma Studioを立ち上げることができます

npx prisma studio

コマンド実行後、http://localhost:5555 でGUIでのDB操作ができます

まとめ

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

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

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

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

1
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
1
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?