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. 保守性

  • 関数の単一責任

  • ユーティリティの再利用

  • 適切なエラー処理

この実装アプローチの利点

  1. テスタビリティ

    • 純粋関数による予測可能な動作

    • 依存性の注入が容易

    • モックが作りやすい

  2. スケーラビリティ

    • 機能の追加が容易

    • コードの再利用性が高い

    • 関心の分離が明確

  3. 保守性

    • エラーの発見が容易

    • コードの意図が明確

    • 変更の影響範囲が予測しやすい

いいなと思ったら応援しよう!