はじめに
こんにちは、株式会社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エンドポイントとして利用可能です。
主な統合ステップは以下の通りです:
-
@trpc/server
と@trpc/client
をインストール - サーバー側にルーター(API)を定義
- クライアント側で
createTRPCProxyClient
を使って呼び出し - 型は自動で補完され、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
まずは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を確認しましょう
ユーザーの作成画面と作成したユーザーの表が画面に表示されているはずです
型共有のメリット
では、型共有を確認するため、ここから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の真骨頂ですね
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
しなおすと、画面の表に年齢が表示されます!
おまけ: Prisma Studio
下記コマンドでPrisma Studioを立ち上げることができます
npx prisma studio
コマンド実行後、http://localhost:5555 でGUIでのDB操作ができます
まとめ
本記事では、tRPC × Next.js × Prisma を使って、型安全かつシンプルなフルスタック開発を体験しました。
tRPC を導入することで、クライアントとサーバー間で 自動的に型を共有でき、APIの定義やスキーマ生成が不要になります。Prisma と組み合わせることで、データベース操作も型安全に行うことができ、DB → API → クライアント という一連の流れを通して一貫した型保証を得られるのが大きなメリットです。
tRPC の最大の魅力は「型のズレによるバグをゼロに近づける」ことです。
機会があればぜひ試してみてください
この記事が皆さんの開発の一助になれば幸いです!