メインコンテンツへスキップ
#Web制作 約7分で読めます

Next.js 15 Dynamic API Routes で REST・GraphQL を同時提供する型安全バックエンド実装

Next.js 15 Dynamic API Routes を活用して REST API と GraphQL を統合し、TypeScript 型安全性を保ちながら効率的なバックエンド処理を実装する実践ガイド

Next.js 15 の Dynamic API Routes を使えば、単一のルートファイルで複雑な API エンドポイントを効率的に管理できます。本記事では、REST API と GraphQL を同時に提供し、TypeScript の型安全性を最大限活用したバックエンド実装パターンを解説します。

Next.js 15 Dynamic API Routes の基本アーキテクチャ

Next.js 15 App Router の Dynamic API Routes は、app/api/[...params]/route.ts のように定義することで、複数のパスパターンを単一のファイルで処理できます。これにより、従来の Pages Router で必要だった複数ファイルの管理が不要になります。

// app/api/[...slug]/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: { slug: string[] } }
) {
  const path = params.slug.join('/')
  
  // パスに応じた処理分岐
  if (path.startsWith('rest')) {
    return handleRestAPI(request, path)
  } else if (path === 'graphql') {
    return handleGraphQL(request)
  }
  
  return NextResponse.json({ error: 'Not Found' }, { status: 404 })
}

アーキテクチャ図

flowchart TD
    A["クライアント"] --> B{"Dynamic API Route\n/api/[...slug]"}
    B -->|/api/rest/users| C["REST Handler"]
    B -->|/api/graphql| D["GraphQL Handler"]
    C --> E["TypeScript型定義"]
    D --> E
    E --> F["データレイヤー"]
    F --> G["データベース/外部API"]
    C --> H["REST Response"]
    D --> I["GraphQL Response"]

Dynamic API Routes を使用することで、単一のエントリーポイントから複数の API 形式を提供し、共通の型定義とミドルウェア処理を適用できます。

TypeScript 型安全性を保証する実装パターン

Next.js 15 の Dynamic API Routes で型安全性を確保するには、リクエスト・レスポンスの型定義と、パラメータバリデーションを組み合わせます。

共通型定義の構築

// types/api.ts
export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'

export type APIRouteParams = {
  slug: string[]
}

export type APIContext<T = unknown> = {
  params: APIRouteParams
  searchParams?: Record<string, string | string[]>
  body?: T
  headers: Headers
}

export type APIResponse<T = unknown> = {
  data?: T
  error?: {
    code: string
    message: string
    details?: unknown
  }
  meta?: {
    timestamp: string
    requestId: string
  }
}

// REST APIエンドポイント型定義
export type UserResource = {
  id: string
  name: string
  email: string
  createdAt: string
}

export type CreateUserRequest = {
  name: string
  email: string
  password: string
}

export type UpdateUserRequest = Partial<Omit<UserResource, 'id' | 'createdAt'>>

Zod によるバリデーション統合

// lib/validation.ts
import { z } from 'zod'
import { NextRequest } from 'next/server'

export const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  password: z.string().min(8).max(100)
})

export const updateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  email: z.string().email().optional()
})

export async function validateRequestBody<T>(
  request: NextRequest,
  schema: z.ZodSchema<T>
): Promise<{ success: true; data: T } | { success: false; error: string }> {
  try {
    const body = await request.json()
    const validated = schema.parse(body)
    return { success: true, data: validated }
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        success: false,
        error: error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')
      }
    }
    return { success: false, error: 'Invalid request body' }
  }
}

型安全な REST API ハンドラー

// app/api/[...slug]/handlers/rest.ts
import { NextRequest, NextResponse } from 'next/server'
import { validateRequestBody, createUserSchema, updateUserSchema } from '@/lib/validation'
import type { APIResponse, UserResource, CreateUserRequest } from '@/types/api'

export async function handleRestAPI(
  request: NextRequest,
  path: string
): Promise<NextResponse<APIResponse>> {
  const segments = path.split('/').filter(Boolean)
  const [, resource, id] = segments // ['rest', 'users', '123']
  
  if (resource === 'users') {
    return handleUserResource(request, id)
  }
  
  return NextResponse.json(
    { error: { code: 'NOT_FOUND', message: 'Resource not found' } },
    { status: 404 }
  )
}

async function handleUserResource(
  request: NextRequest,
  id?: string
): Promise<NextResponse<APIResponse<UserResource | UserResource[]>>> {
  switch (request.method) {
    case 'GET':
      if (id) {
        const user = await getUserById(id)
        return NextResponse.json({ data: user })
      }
      const users = await getAllUsers()
      return NextResponse.json({ data: users })
      
    case 'POST': {
      const validation = await validateRequestBody(request, createUserSchema)
      if (!validation.success) {
        return NextResponse.json(
          { error: { code: 'VALIDATION_ERROR', message: validation.error } },
          { status: 400 }
        )
      }
      const newUser = await createUser(validation.data)
      return NextResponse.json({ data: newUser }, { status: 201 })
    }
    
    case 'PATCH': {
      if (!id) {
        return NextResponse.json(
          { error: { code: 'BAD_REQUEST', message: 'User ID required' } },
          { status: 400 }
        )
      }
      const validation = await validateRequestBody(request, updateUserSchema)
      if (!validation.success) {
        return NextResponse.json(
          { error: { code: 'VALIDATION_ERROR', message: validation.error } },
          { status: 400 }
        )
      }
      const updatedUser = await updateUser(id, validation.data)
      return NextResponse.json({ data: updatedUser })
    }
    
    case 'DELETE': {
      if (!id) {
        return NextResponse.json(
          { error: { code: 'BAD_REQUEST', message: 'User ID required' } },
          { status: 400 }
        )
      }
      await deleteUser(id)
      return NextResponse.json({ data: null }, { status: 204 })
    }
    
    default:
      return NextResponse.json(
        { error: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed' } },
        { status: 405 }
      )
  }
}

GraphQL と REST API の統合実装

Next.js 15 Dynamic API Routes では、GraphQL と REST API を同じルートファイルから提供できます。GraphQL Schema と TypeScript 型定義を共有することで、型安全性を維持しながら効率的な開発が可能です。

GraphQL Yoga による統合

// lib/graphql/schema.ts
import { createSchema } from 'graphql-yoga'
import type { UserResource } from '@/types/api'

export const schema = createSchema<{
  Query: {
    user: (parent: unknown, args: { id: string }) => Promise<UserResource | null>
    users: () => Promise<UserResource[]>
  }
  Mutation: {
    createUser: (parent: unknown, args: { input: CreateUserInput }) => Promise<UserResource>
    updateUser: (parent: unknown, args: { id: string; input: UpdateUserInput }) => Promise<UserResource>
    deleteUser: (parent: unknown, args: { id: string }) => Promise<boolean>
  }
}>({
  typeDefs: `
    type User {
      id: ID!
      name: String!
      email: String!
      createdAt: String!
    }
    
    input CreateUserInput {
      name: String!
      email: String!
      password: String!
    }
    
    input UpdateUserInput {
      name: String
      email: String
    }
    
    type Query {
      user(id: ID!): User
      users: [User!]!
    }
    
    type Mutation {
      createUser(input: CreateUserInput!): User!
      updateUser(id: ID!, input: UpdateUserInput!): User!
      deleteUser(id: ID!): Boolean!
    }
  `,
  resolvers: {
    Query: {
      user: async (_, { id }) => {
        return getUserById(id)
      },
      users: async () => {
        return getAllUsers()
      }
    },
    Mutation: {
      createUser: async (_, { input }) => {
        return createUser(input)
      },
      updateUser: async (_, { id, input }) => {
        return updateUser(id, input)
      },
      deleteUser: async (_, { id }) => {
        await deleteUser(id)
        return true
      }
    }
  }
})

GraphQL ハンドラーの実装

// app/api/[...slug]/handlers/graphql.ts
import { createYoga } from 'graphql-yoga'
import { schema } from '@/lib/graphql/schema'
import type { NextRequest } from 'next/server'

const yoga = createYoga({
  schema,
  graphqlEndpoint: '/api/graphql',
  fetchAPI: { Request: Request, Response: Response }
})

export async function handleGraphQL(request: NextRequest) {
  return yoga.handle(request)
}

統合ルートファイル

// app/api/[...slug]/route.ts
import { NextRequest } from 'next/server'
import { handleRestAPI } from './handlers/rest'
import { handleGraphQL } from './handlers/graphql'

export async function GET(
  request: NextRequest,
  { params }: { params: { slug: string[] } }
) {
  return routeRequest(request, params, 'GET')
}

export async function POST(
  request: NextRequest,
  { params }: { params: { slug: string[] } }
) {
  return routeRequest(request, params, 'POST')
}

export async function PATCH(
  request: NextRequest,
  { params }: { params: { slug: string[] } }
) {
  return routeRequest(request, params, 'PATCH')
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { slug: string[] } }
) {
  return routeRequest(request, params, 'DELETE')
}

async function routeRequest(
  request: NextRequest,
  params: { slug: string[] },
  method: string
) {
  const path = params.slug.join('/')
  
  // GraphQLリクエストの処理
  if (path === 'graphql') {
    return handleGraphQL(request)
  }
  
  // RESTリクエストの処理
  if (path.startsWith('rest/')) {
    return handleRestAPI(request, path)
  }
  
  return new Response(JSON.stringify({ error: 'Not Found' }), {
    status: 404,
    headers: { 'Content-Type': 'application/json' }
  })
}

API リクエストフロー

sequenceDiagram
    participant Client
    participant Route as /api/[...slug]
    participant REST as REST Handler
    participant GQL as GraphQL Handler
    participant Validation
    participant DB as Database
    
    Client->>Route: POST /api/rest/users
    Route->>REST: handleRestAPI()
    REST->>Validation: validateRequestBody()
    Validation-->>REST: validated data
    REST->>DB: createUser()
    DB-->>REST: UserResource
    REST-->>Route: NextResponse
    Route-->>Client: JSON Response
    
    Client->>Route: POST /api/graphql
    Route->>GQL: handleGraphQL()
    GQL->>GQL: parse query
    GQL->>DB: resolver()
    DB-->>GQL: data
    GQL-->>Route: GraphQL Response
    Route-->>Client: JSON Response

エラーハンドリングとロギング

型安全なエラーハンドリングとリクエストロギングを実装することで、運用時のトラブルシューティングを効率化できます。

統一エラー型とハンドラー

// lib/errors.ts
export class APIError extends Error {
  constructor(
    public code: string,
    message: string,
    public statusCode: number = 500,
    public details?: unknown
  ) {
    super(message)
    this.name = 'APIError'
  }
}

export class ValidationError extends APIError {
  constructor(message: string, details?: unknown) {
    super('VALIDATION_ERROR', message, 400, details)
  }
}

export class NotFoundError extends APIError {
  constructor(resource: string) {
    super('NOT_FOUND', `${resource} not found`, 404)
  }
}

export class UnauthorizedError extends APIError {
  constructor(message: string = 'Unauthorized') {
    super('UNAUTHORIZED', message, 401)
  }
}

// lib/error-handler.ts
import { NextResponse } from 'next/server'
import { APIError } from './errors'
import type { APIResponse } from '@/types/api'

export function handleAPIError(error: unknown): NextResponse<APIResponse> {
  if (error instanceof APIError) {
    return NextResponse.json(
      {
        error: {
          code: error.code,
          message: error.message,
          details: error.details
        }
      },
      { status: error.statusCode }
    )
  }
  
  console.error('Unexpected error:', error)
  
  return NextResponse.json(
    {
      error: {
        code: 'INTERNAL_SERVER_ERROR',
        message: 'An unexpected error occurred'
      }
    },
    { status: 500 }
  )
}

リクエストロギングミドルウェア

// lib/middleware/logging.ts
import type { NextRequest, NextResponse } from 'next/server'

export type LogContext = {
  requestId: string
  method: string
  path: string
  timestamp: string
  duration?: number
  statusCode?: number
  error?: string
}

export async function withLogging<T extends NextResponse>(
  request: NextRequest,
  handler: () => Promise<T>
): Promise<T> {
  const requestId = crypto.randomUUID()
  const startTime = Date.now()
  
  const logContext: LogContext = {
    requestId,
    method: request.method,
    path: request.nextUrl.pathname,
    timestamp: new Date().toISOString()
  }
  
  try {
    const response = await handler()
    
    logContext.duration = Date.now() - startTime
    logContext.statusCode = response.status
    
    console.log('[API Request]', JSON.stringify(logContext))
    
    return response
  } catch (error) {
    logContext.duration = Date.now() - startTime
    logContext.error = error instanceof Error ? error.message : 'Unknown error'
    
    console.error('[API Error]', JSON.stringify(logContext))
    
    throw error
  }
}

エラーハンドリング統合例

// app/api/[...slug]/route.ts(修正版)
import { withLogging } from '@/lib/middleware/logging'
import { handleAPIError } from '@/lib/error-handler'

export async function POST(
  request: NextRequest,
  { params }: { params: { slug: string[] } }
) {
  return withLogging(request, async () => {
    try {
      return await routeRequest(request, params, 'POST')
    } catch (error) {
      return handleAPIError(error)
    }
  })
}

レート制限と認証の実装

本番環境では、API エンドポイントのレート制限と認証が必須です。Next.js 15 Dynamic API Routes では、ミドルウェア層でこれらを一元管理できます。

Upstash Redis によるレート制限

// lib/middleware/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { NextRequest, NextResponse } from 'next/server'
import type { APIResponse } from '@/types/api'

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!
})

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10秒間に10リクエスト
  analytics: true
})

export async function withRateLimit(
  request: NextRequest,
  handler: () => Promise<NextResponse>
): Promise<NextResponse<APIResponse>> {
  const ip = request.ip ?? '127.0.0.1'
  const { success, limit, reset, remaining } = await ratelimit.limit(ip)
  
  if (!success) {
    return NextResponse.json(
      {
        error: {
          code: 'RATE_LIMIT_EXCEEDED',
          message: 'Too many requests',
          details: {
            limit,
            remaining,
            reset: new Date(reset).toISOString()
          }
        }
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString()
        }
      }
    )
  }
  
  const response = await handler()
  response.headers.set('X-RateLimit-Limit', limit.toString())
  response.headers.set('X-RateLimit-Remaining', remaining.toString())
  response.headers.set('X-RateLimit-Reset', reset.toString())
  
  return response
}

JWT 認証ミドルウェア

// lib/middleware/auth.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
import { UnauthorizedError } from '@/lib/errors'
import type { APIResponse } from '@/types/api'

export type AuthContext = {
  userId: string
  email: string
}

export async function withAuth(
  request: NextRequest,
  handler: (context: AuthContext) => Promise<NextResponse>
): Promise<NextResponse<APIResponse>> {
  const authHeader = request.headers.get('authorization')
  
  if (!authHeader?.startsWith('Bearer ')) {
    return NextResponse.json(
      {
        error: {
          code: 'UNAUTHORIZED',
          message: 'Missing or invalid authorization header'
        }
      },
      { status: 401 }
    )
  }
  
  const token = authHeader.substring(7)
  
  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
    const { payload } = await jwtVerify(token, secret)
    
    return handler({
      userId: payload.sub as string,
      email: payload.email as string
    })
  } catch (error) {
    return NextResponse.json(
      {
        error: {
          code: 'UNAUTHORIZED',
          message: 'Invalid or expired token'
        }
      },
      { status: 401 }
    )
  }
}

ミドルウェア連鎖の実装

// app/api/[...slug]/route.ts(完全版)
import { withLogging } from '@/lib/middleware/logging'
import { withRateLimit } from '@/lib/middleware/rate-limit'
import { withAuth } from '@/lib/middleware/auth'
import { handleAPIError } from '@/lib/error-handler'

export async function POST(
  request: NextRequest,
  { params }: { params: { slug: string[] } }
) {
  return withLogging(request, async () => {
    return withRateLimit(request, async () => {
      return withAuth(request, async (authContext) => {
        try {
          // authContext を使用した認証済みリクエスト処理
          return await routeRequest(request, params, 'POST', authContext)
        } catch (error) {
          return handleAPIError(error)
        }
      })
    })
  })
}

パフォーマンス最適化

Dynamic API Routes のパフォーマンスを最適化するには、データフェッチのキャッシュ、並列処理、遅延ロードなどの手法を組み合わせます。

Vercel Edge Runtime の活用

// app/api/[...slug]/route.ts
export const runtime = 'edge' // Edge Runtime を有効化

// Edge Runtime では Node.js API が制限されるため注意
// - fs モジュール不可
// - 一部の ORM が動作しない可能性
// - 軽量な処理に適している

Edge Runtime を使用することで、グローバルなエッジロケーションから API を提供し、レイテンシを削減できます。ただし、Node.js 固有の API が使えないため、重い処理には Node.js Runtime を選択します。

データフェッチのキャッシュ戦略

// lib/cache.ts
import { unstable_cache } from 'next/cache'

export const getCachedUsers = unstable_cache(
  async () => {
    // データベースから全ユーザーを取得
    return getAllUsers()
  },
  ['all-users'],
  {
    revalidate: 60, // 60秒間キャッシュ
    tags: ['users']
  }
)

export const getCachedUser = unstable_cache(
  async (id: string) => {
    return getUserById(id)
  },
  ['user-by-id'],
  {
    revalidate: 60,
    tags: ['users']
  }
)

// キャッシュの無効化
import { revalidateTag } from 'next/cache'

export async function invalidateUserCache() {
  revalidateTag('users')
}

並列データフェッチの最適化

// app/api/[...slug]/handlers/rest.ts
async function getUserWithRelations(userId: string) {
  // Promise.all で並列実行
  const [user, posts, comments] = await Promise.all([
    getUserById(userId),
    getPostsByUserId(userId),
    getCommentsByUserId(userId)
  ])
  
  return {
    ...user,
    posts,
    comments
  }
}

まとめ

本記事では、Next.js 15 Dynamic API Routes を使用して、REST API と GraphQL を同時に提供する型安全なバックエンド実装パターンを解説しました。

重要ポイント:

  • Dynamic API Routes で単一のルートファイルから複数の API 形式を提供
  • TypeScript + Zod でリクエスト・レスポンスの型安全性を保証
  • GraphQL Yoga と REST API を統合し、共通の型定義を活用
  • 統一エラーハンドリングとロギングで運用性を向上
  • レート制限・JWT 認証をミドルウェア層で一元管理
  • Edge Runtime とキャッシュ戦略でパフォーマンスを最適化

Next.js 15 の Dynamic API Routes を活用することで、従来の Pages Router で必要だった複数ファイルの管理が不要になり、型安全性とコードの保守性を高めながら効率的なバックエンド開発が可能です。

参考リンク

#Next.js #API Routes #TypeScript #GraphQL #バックエンド
シェア