Next.js Route Handlers で作る Prisma データフェッチ API
はじめに
Route Handlers は Next.js App Router における API エンドポイントの実装方法として広く使われています。
最近実務でも外部公開向けの API エンドポイントを作るときに Route Handlers を使用するケースも増えてきたので、知見として、特に Prisma と組み合わせた際の実践的な実装パターンを紹介します。
具体的には、効率的なキャッシュ戦略、N+1 問題の解決、そして適切なエラーハンドリングについて解説します。
1. 基本設定と型定義
まず、アプリケーション全体で使用する型や設定を定義します。
// types/dto/user.ts
export interface UserDTO {
id: string;
name: string;
email: string;
role: string;
lastLoginAt: string;
}
// types/api/error.ts
export interface ApiErrorResponse {
code: string;
message: string;
details?: Record<string, unknown>;
}
export interface ApiSuccessResponse<T> {
data: T;
meta?: Record<string, unknown>;
}
// config/api.ts
export const API_CONFIG = {
REVALIDATE_INTERVALS: {
USERS: 60, // 1分
POSTS: 300, // 5分
COMMENTS: 30, // 30秒
},
CACHE_TAGS: {
USERS: 'users',
POSTS: 'posts',
COMMENTS: 'comments',
},
} as const;
// types/api/params.ts
export interface PaginationParams {
page?: number;
limit?: number;
cursor?: string;
}
export interface FilterParams {
searchTerm?: string;
status?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
2. ユーティリティ関数の実装
エラーハンドリング
// utils/error.ts
import { Prisma } from '@prisma/client';
import { ApiErrorResponse } from '@/types/api/error';
export function createApiError(
code: string,
message: string,
statusCode: number,
details?: Record<string, unknown>
) {
return {
error: { code, message, details },
statusCode,
};
}
export function handlePrismaError(error: Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
return createApiError(
'DUPLICATE_ENTRY',
'既に登録されているデータです',
400,
{ fields: error.meta?.target }
);
}
if (error.code === 'P2025') {
return createApiError(
'NOT_FOUND',
'指定されたリソースが見つかりません',
404
);
}
return createApiError(
'DATABASE_ERROR',
'データベースエラーが発生しました',
500
);
}
export function handleApiError(error: unknown) {
console.error('API Error:', error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return handlePrismaError(error);
}
// 予期せぬエラー
return createApiError(
'INTERNAL_SERVER_ERROR',
'予期せぬエラーが発生しました',
500
);
}
// utils/response.ts
export function createApiResponse<T>(
data: T,
meta?: Record<string, unknown>
): ApiSuccessResponse<T> {
return { data, ...(meta && { meta }) };
}
データ変換とバリデーション
// utils/transform.ts
import { User } from '@prisma/client';
import { UserDTO } from '@/types/dto/user';
export function toUserDTO(user: User): UserDTO {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
lastLoginAt: user.lastLoginAt.toISOString(),
};
}
// utils/validation.ts
import { z } from 'zod';
import { PaginationParams, FilterParams } from '@/types/api/params';
export const paginationSchema = z.object({
page: z.coerce.number().positive().optional(),
limit: z.coerce.number().positive().max(100).optional(),
cursor: z.string().optional(),
});
export const filterSchema = z.object({
searchTerm: z.string().optional(),
status: z.string().optional(),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
});
export function validateQueryParams<T extends Record<string, unknown>>(
params: T,
schema: z.ZodType<T>
) {
const result = schema.safeParse(params);
if (!result.success) {
throw createApiError(
'INVALID_PARAMS',
'パラメータが不正です',
400,
{ errors: result.error.flatten() }
);
}
return result.data;
}
3. DataLoader の実装
// utils/dataloader.ts
import DataLoader from 'dataloader';
import { prisma } from '@/lib/prisma';
export function createDataLoader<T extends { id: string }>(
modelName: 'user' | 'comment' | 'post',
options?: {
select?: Record<string, boolean>;
}
) {
return new DataLoader<string, T | null>(async (ids: readonly string[]) => {
// @ts-expect-error: prismaのモデル名による動的な呼び出し
const items = await prisma[modelName].findMany({
where: { id: { in: [...ids] } },
...(options?.select ? { select: options.select } : {}),
});
const itemMap = new Map(items.map(item => [item.id, item]));
return ids.map(id => itemMap.get(id) || null);
});
}
// utils/context.ts
import { createDataLoader } from './dataloader';
import { v4 as uuidv4 } from 'uuid';
const contextMap = new Map();
interface ApiContext {
userLoader: ReturnType<typeof createDataLoader<User>>;
commentLoader: ReturnType<typeof createDataLoader<Comment>>;
clearAll: () => void;
}
export function createApiContext(): ApiContext {
const userLoader = createDataLoader<User>('user', {
select: {
id: true,
name: true,
email: true,
role: true,
},
});
const commentLoader = createDataLoader<Comment>('comment');
return {
userLoader,
commentLoader,
clearAll: () => {
userLoader.clearAll();
commentLoader.clearAll();
},
};
}
export function getApiContext(requestId: string = uuidv4()): ApiContext {
if (!contextMap.has(requestId)) {
contextMap.set(requestId, createApiContext());
}
return contextMap.get(requestId)!;
}
// middleware/withApiContext.ts
import { NextResponse } from 'next/server';
import { getApiContext } from '../utils/context';
export function withApiContext(
handler: (
req: Request,
context: ReturnType<typeof createApiContext>
) => Promise<NextResponse>
) {
return async (req: Request) => {
const requestId = uuidv4();
const context = getApiContext(requestId);
try {
return await handler(req, context);
} finally {
context.clearAll();
contextMap.delete(requestId);
}
};
}
4. キャッシュ制御の実装
// utils/cache.ts
import { API_CONFIG } from '@/config/api';
import { revalidateTag } from 'next/cache';
interface CacheOptions {
revalidate?: number;
tags?: string[];
}
export function createCacheConfig(
modelName: keyof typeof API_CONFIG.REVALIDATE_INTERVALS
): CacheOptions {
return {
revalidate: API_CONFIG.REVALIDATE_INTERVALS[modelName],
tags: [API_CONFIG.CACHE_TAGS[modelName]],
};
}
export async function invalidateCache(tags: string[]) {
await Promise.all(tags.map(tag => revalidateTag(tag)));
}
// utils/fetch.ts
export async function fetchWithCache<T>(
url: string,
options: CacheOptions
): Promise<T> {
const res = await fetch(url, {
next: {
revalidate: options.revalidate,
tags: options.tags,
},
});
if (!res.ok) {
throw createApiError(
'FETCH_ERROR',
'データの取得に失敗しました',
res.status
);
}
return res.json();
}
5. API実装例
// app/api/posts/route.ts
import { withApiContext } from '@/middleware/withApiContext';
import { createCacheConfig, invalidateCache } from '@/utils/cache';
import { validateQueryParams } from '@/utils/validation';
import { createApiResponse } from '@/utils/response';
import { handleApiError } from '@/utils/error';
export const GET = withApiContext(async (req, context) => {
try {
const { searchParams } = new URL(req.url);
const params = validateQueryParams(
Object.fromEntries(searchParams),
paginationSchema.merge(filterSchema)
);
const posts = await prisma.post.findMany({
where: {
...(params.searchTerm && {
OR: [
{ title: { contains: params.searchTerm } },
{ content: { contains: params.searchTerm } },
],
}),
...(params.status && { status: params.status }),
},
orderBy: params.sortBy
? { [params.sortBy]: params.sortOrder ?? 'desc' }
: undefined,
take: params.limit ?? 10,
skip: params.page ? (params.page - 1) * (params.limit ?? 10) : undefined,
cursor: params.cursor ? { id: params.cursor } : undefined,
select: {
id: true,
title: true,
content: true,
authorId: true,
createdAt: true,
},
});
// DataLoader を使用して著者情報を一括取得
const authors = await context.userLoader.loadMany(
posts.map(post => post.authorId)
);
const postsWithAuthor = posts.map((post, index) => ({
...post,
author: authors[index],
}));
return NextResponse.json(
createApiResponse(postsWithAuthor),
{
headers: {
'Cache-Control': `public, s-maxage=${API_CONFIG.REVALIDATE_INTERVALS.POSTS}`,
},
}
);
} catch (error) {
const { error: apiError, statusCode } = handleApiError(error);
return NextResponse.json(apiError, { status: statusCode });
}
});
export const POST = withApiContext(async (req, context) => {
try {
const data = await req.json();
// バリデーションなどの処理は省略
const post = await prisma.post.create({
data,
select: {
id: true,
title: true,
content: true,
authorId: true,
},
});
// キャッシュの無効化
await invalidateCache([API_CONFIG.CACHE_TAGS.POSTS]);
return NextResponse.json(
createApiResponse(post),
{ status: 201 }
);
} catch (error) {
const { error: apiError, statusCode } = handleApiError(error);
return NextResponse.json(apiError, { status: statusCode });
}
});
6. クライアントサイドの実装例
// hooks/usePosts.ts
import { useQuery } from '@tanstack/react-query';
import { fetchWithCache } from '@/utils/fetch';
import { API_CONFIG } from '@/config/api';
export function usePosts(params: PaginationParams & FilterParams) {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, value]) => value != null)
).toString();
return useQuery({
queryKey: ['posts', params],
queryFn: () => fetchWithCache(
`/api/posts?${queryString}`,
createCacheConfig('POSTS')
),
});
}
実装のベストプラクティス
1. エラーハンドリング
一貫性のあるエラーレスポンス形式
適切なエラーログ記録
クライアントにわかりやすいエラーメッセージ
2. パフォーマンス最適化
DataLoader によるバッチ処理
適切なキャッシュ戦略
必要なデータのみを取得
3. 型安全性
DTOによるレスポンス型の定義
zod によるバリデーション
TypeScript の厳密な型チェック
4. 保守性
関数の単一責任
ユーティリティの再利用
適切なエラー処理
この実装アプローチの利点
テスタビリティ
純粋関数による予測可能な動作
依存性の注入が容易
モックが作りやすい
スケーラビリティ
機能の追加が容易
コードの再利用性が高い
関心の分離が明確
保守性
エラーの発見が容易
コードの意図が明確
変更の影響範囲が予測しやすい