Next.js API Routes を実務で使いこなす:実装パターンと注意点

React / Next.js

Next.js API Routes を実務で使いこなす:実装パターンと注意点

フロントエンドとバックエンドの開発を効率化したいなら、Next.js API Routes は欠かせません。本記事では、実務レベルの実装パターンを交えながら、実際のプロジェクトで役立つ使い方を詳しく解説していきます。

Next.js API Routes とは何か

Next.js API Routes は、Next.jsアプリケーション内にサーバーサイドのAPIエンドポイントを直接構築できる機能です。別途バックエンドサーバーを用意せず、フロントエンド側で API を実装できるため、開発スピードが格段に向上します。

pages/api ディレクトリに配置したファイルが自動的にルートとして認識され、REST API のエンドポイントになります。

pages/
├── api/
│   ├── users.ts          # GET /api/users
│   ├── users/[id].ts     # GET /api/users/[id]
│   └── auth/login.ts     # POST /api/auth/login
├── index.tsx
└── ...

実務でよく使用するユースケース

実際のプロジェクトでは、以下のようなシーンで API Routes が活躍します。

1. フォームの送信処理とメール送信

問い合わせフォームの送信後、メール配信を行う場合、外部サービスの認証情報をクライアント側に露出させたくないので、API Routes を経由して処理します。

2. 認証・認可の処理

ログイン、トークン検証、ユーザー情報の取得など、セキュリティが必要な処理は API Routes で実装します。

3. データベース操作

ユーザーデータや商品情報など、データベースへのアクセスはクライアント側からは直接できないため、API Routes を窓口にします。

4. 外部 API との連携

決済サービスや地図API、天気APIなど、外部のサードパーティ API を呼び出す際に、キーの隠蔽と通信の安全性確保が必要です。

実装コード:実務レベルの API Route

基本的な構造とエラーハンドリング

まずは、エラーハンドリングを含めた基本的なAPI Routeの実装です。実務では、適切なステータスコードと エラーメッセージの返却が必須です。

// pages/api/users/[id].ts
import { NextApiRequest, NextApiResponse } from 'next'

// レスポンス型の定義
type Data = {
  id?: string
  name?: string
  email?: string
  error?: string
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  const { id } = req.query

  // メソッドチェック
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  // パラメータ検証
  if (!id || typeof id !== 'string') {
    return res.status(400).json({ error: 'Invalid user ID' })
  }

  try {
    // 実装例:ユーザー情報の取得(実際はDB接続)
    const user = {
      id,
      name: 'Taro Yamada',
      email: 'taro@example.com'
    }

    res.status(200).json(user)
  } catch (error) {
    console.error('Error fetching user:', error)
    res.status(500).json({ error: 'Internal server error' })
  }
}

POST リクエストの実装:データベース保存

フォームデータを受け取り、データベースに保存する実装パターンです。入力値の検証とトランザクション処理を含めています。

// pages/api/users.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { db } from '@/lib/firebase' // Firebaseやその他のDBクライアント

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

type CreateUserResponse = {
  id?: string
  message?: string
  error?: string
}

// 入力値検証
function validateUserInput(data: any): data is CreateUserRequest {
  return (
    typeof data.name === 'string' &&
    data.name.length > 0 &&
    typeof data.email === 'string' &&
    /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(data.email) &&
    typeof data.password === 'string' &&
    data.password.length >= 8
  )
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<CreateUserResponse>
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  // CORS チェック(必要に応じて)
  const origin = req.headers.origin
  if (origin && !isAllowedOrigin(origin)) {
    return res.status(403).json({ error: 'CORS policy violation' })
  }

  // リクエストボディの検証
  if (!validateUserInput(req.body)) {
    return res.status(400).json({
      error: 'Invalid input: name, valid email, and password (min 8 chars) required'
    })
  }

  try {
    const { name, email, password } = req.body

    // メールの重複チェック
    const existingUser = await db.collection('users').where('email', '==', email).get()
    if (!existingUser.empty) {
      return res.status(409).json({ error: 'Email already registered' })
    }

    // ユーザー作成
    const docRef = await db.collection('users').add({
      name,
      email,
      password: await hashPassword(password), // 実装省略
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    })

    res.status(201).json({
      id: docRef.id,
      message: 'User created successfully'
    })
  } catch (error) {
    console.error('Error creating user:', error)
    res.status(500).json({ error: 'Failed to create user' })
  }
}

function isAllowedOrigin(origin: string): boolean {
  const allowedOrigins = [
    'https://example.com',
    'https://app.example.com'
  ]
  return allowedOrigins.includes(origin)
}

async function hashPassword(password: string): Promise {
  // bcrypt等を使用してパスワードをハッシュ化
  return password // 実装省略
}

認証付き API Route の実装

実務では、JWT トークンを用いた認証が一般的です。認証ミドルウェアを使用した実装例を示します。

// lib/authMiddleware.ts
import { NextApiRequest, NextApiResponse } from 'next'
import jwt from 'jsonwebtoken'

export interface AuthenticatedRequest extends NextApiRequest {
  userId?: string
  user?: { id: string; email: string }
}

export function withAuth(handler: any) {
  return async (req: AuthenticatedRequest, res: NextApiResponse) => {
    try {
      const token = req.headers.authorization?.split(' ')[1]

      if (!token) {
        return res.status(401).json({ error: 'No token provided' })
      }

      const decoded = jwt.verify(token, process.env.JWT_SECRET!)
      req.userId = decoded.sub
      req.user = decoded

      return handler(req, res)
    } catch (error) {
      return res.status(401).json({ error: 'Invalid token' })
    }
  }
}

// pages/api/profile.ts
import { withAuth, AuthenticatedRequest } from '@/lib/authMiddleware'
import { NextApiResponse } from 'next'

async function profileHandler(
  req: AuthenticatedRequest,
  res: NextApiResponse
) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  try {
    const userProfile = {
      id: req.userId,
      email: req.user?.email,
      name: 'John Doe'
    }

    res.status(200).json(userProfile)
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch profile' })
  }
}

export default withAuth(profileHandler)

外部 API との連携

決済処理やメール送信など、外部サービスの API キーをクライアント側に露出させないための実装です。

// pages/api/send-email.ts
import { NextApiRequest, NextApiResponse } from 'next'
import nodemailer from 'nodemailer'

type EmailRequest = {
  to: string
  subject: string
  body: string
}

type EmailResponse = {
  success?: boolean
  messageId?: string
  error?: string
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<EmailResponse>
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  const { to, subject, body }: EmailRequest = req.body

  if (!to || !subject || !body) {
    return res.status(400).json({
      error: 'Missing required fields: to, subject, body'
    })
  }

  try {
    const transporter = nodemailer.createTransport({
      service: 'gmail',
      auth: {
        user: process.env.GMAIL_USER,
        pass: process.env.GMAIL_APP_PASSWORD
      }
    })

    const info = await transporter.sendMail({
      from: process.env.GMAIL_USER,
      to,
      subject,
      html: body
    })

    res.status(200).json({
      success: true,
      messageId: info.messageId
    })
  } catch (error) {
    console.error('Email sending failed:', error)
    res.status(500).json({
      error: 'Failed to send email'
    })
  }
}

フロントエンドからの呼び出し方

API Route を作成したら、フロントエンドから呼び出します。実務では、axios や fetch API を使用してリクエストを送ります。

// components/UserForm.tsx
import { useState } from 'react'
import axios from 'axios'

export default function UserForm() {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setLoading(true)
    setError('')

    const formData = new FormData(e.currentTarget)
    const data = {
      name: formData.get('name'),
      email: formData.get('email'),
      password: formData.get('password')
    }

    try {
      const response = await axios.post('/api/users', data, {
        headers: {
          'Content-Type': 'application/json'
        }
      })

      alert(`User created: ${response.data.id}`)
    } catch (err) {
      if (axios.isAxiosError(err)) {
        setError(err.response?.data?.error || 'An error occurred')
      } else {
        setError('An unexpected error occurred')
      }
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type=\"text\" name=\"name\" placeholder=\"Name\" required />
      <input type=\"email\" name=\"email\" placeholder=\"Email\" required />
      <input type=\"password\" name=\"password\" placeholder=\"Password\" required />
      <button type=\"submit\" disabled={loading}>
        {loading ? 'Creating...' : 'Create User'}
      </button>
      {error && <p style={{color: 'red'}}>{error}</p>}
    </form>
  )
}

よくある応用パターン

複数のメソッドを1つの API Route で処理

同じリソースに対して、GET、POST、PUT、DELETE など複数のメソッドを処理する場合は、1つのファイル内で分岐させます。

// pages/api/articles/[id].ts
import { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query

  switch (req.method) {
    case 'GET':
      return handleGet(id as string, res)
    case 'PUT':
      return handlePut(id as string, req.body, res)
    case 'DELETE':
      return handleDelete(id as string, res)
    default:
      return res.status(405).json({ error: 'Method not allowed' })
  }
}

async function handleGet(id: string, res: NextApiResponse) {
  // GET ロジック
  res.status(200).json({ id, title: 'Article' })
}

async function handlePut(id: string, body: any, res: NextApiResponse) {
  // PUT ロジック
  res.status(200).json({ id, message: 'Updated' })
}

async function handleDelete(id: string, res: NextApiResponse) {
  // DELETE ロジック
  res.status(200).json({ message: 'Deleted' })
}

ページネーション付き一覧取得

データが多い場合は、ページネーションで対応します。

// pages/api/articles.ts
import { NextApiRequest, NextApiResponse } from 'next'

type ListResponse = {
  data: any[]
  total: number
  page: number
  limit: number
  error?: string
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ListResponse>
) {
  if (req.method !== 'GET') {
    return res.status(405).json({
      data: [],
      total: 0,
      page: 0,
      limit: 0,
      error: 'Method not allowed'
    })
  }

  const page = parseInt(req.query.page as string) || 1
  const limit = parseInt(req.query.limit as string) || 10

  if (page < 1 || limit < 1 || limit > 100) {
    return res.status(400).json({
      data: [],
      total: 0,
      page,
      limit,
      error: 'Invalid pagination parameters'
    })
  }

  try {
    // DB から総数とデータを取得(実装省略)
    const offset = (page - 1) * limit
    const articles = [
      { id: 1, title: 'Article 1' },
      { id: 2, title: 'Article 2' }
    ]
    const total = 42

    res.status(200).json({
      data: articles,
      total,
      page,
      limit
    })
  } catch (error) {
    res.status(500).json({
      data: [],
      total: 0,
      page,
      limit,
      error: 'Failed to fetch articles'
    })
  }
}

ファイルアップロード の処理

画像やドキュメントのアップロードは、特別な処理が必要です。multipart/form-data の解析に formidable ライブラリを使用します。

// pages/api/upload.ts
import { NextApiRequest, NextApiResponse } from 'next'
import formidable from 'formidable'
import fs from 'fs/promises'
import path from 'path'

export const config = {
  api: {
    bodyParser: false
  }
}

type UploadResponse = {
  filename?: string
  size?: number
  error?: string
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<UploadResponse>
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  const form = formidable({
    uploadDir: path.join(process.cwd(), 'public/uploads'),
    keepExtensions: true,
    maxFileSize: 10 * 1024 * 1024 // 10MB
  })

  try {
    const [fields, files] = await form.parse(req)

    const file = files.file?.[0]
    if (!file) {
      return res.status(400).json({ error: 'No file provided' })
    }

    // 許可するファイルタイプの確認
    const allowedMimes = ['image/jpeg', 'image/png', 'image/webp']
    if (!allowedMimes.includes(file.mimetype || '')) {
      await fs.unlink(file.filepath)
      return res.status(400).json({ error: 'Invalid file type' })
    }

    const filename = path.basename(file.filepath)

    res.status(200).json({
      filename,
      size: file.size
    })
  } catch (error) {
    console.error('Upload error:', error)
    res.status(500).json({ error: 'Upload failed' })
  }
}

実務における注意点

1. セキュリティ対策は必須

API Routes で扱うのはサーバーサイドコードです。以下の点に注意します:

  • 環境変数の使用:API キー、データベース認証情報は環境変数に保存
  • 入力値の検証:すべてのリクエストボディとクエリパラメータを検証
  • 認証・認可:必要な API には認証トークンのチェックを実装
  • CORS の設定:不正なオリジンからのリクエストを遮断
// lib/corsMiddleware.ts
import { NextApiRequest, NextApiResponse } from 'next'

export function withCors(handler: any) {
  return (req: NextApiRequest, res: NextApiResponse) => {
    res.setHeader('Access-Control-Allow-Origin', process.env.ALLOWED_ORIGIN || '*')
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')

    if (req.method === 'OPTIONS') {
      return res.status(200).end()
    }

    return handler(req, res)
  }
}

2. データベース接続の管理

API Route が呼ばれるたびに新しい接続を作成すると、パフォーマンスが低下します。コネクションプーリングの使用やシングルトンパターンで接続を再利用します。

// lib/db.ts
import { MongoClient } from 'mongodb'

let cachedClient: MongoClient | null = null

export async function connectToDatabase() {
  if (cachedClient) {
    return cachedClient
  }

  const client = new MongoClient(process.env.MONGODB_URI!)
  await client.connect()

  cachedClient = client
  return client
}

3. ロギングとモニタリング

本番環境では、エラーログを記録し、異常なアクセスを検知する仕組みが必要です。

// lib/logger.ts
export function logError(context: string, error: any) {
  console.error(`[${new Date().toISOString()}] ${context}:`, error)

  // 外部ロギングサービスへの送信
  if (process.env.NODE_ENV === 'production') {
    // Sentry、CloudWatch 等への送信
  }
}

4. パフォーマンスと リソース制限

Vercel など、サーバーレス環境では実行時間の制限があります。長い処理は非同期ジョブキューで処理するのがベストプラクティスです。

// pages/api/process-report.ts
import { NextApiRequest, NextApiResponse } from 'next'
import { queue } from '@/lib/jobQueue'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  try {
    const jobId = await queue.add('generate-report', req.body)

    res.status(202).json({
      message: 'Report generation started',
      jobId
    })
  } catch (error) {
    res.status(500).json({ error: 'Failed to queue job' })
  }
}

5. レート制限の実装

API の乱用を防ぐため、IP アドレスやユーザーごとのレート制限を設定します。

// lib/rateLimit.ts
import { NextApiRequest } from 'next'

const store = new Map<string, number[]>()

export function rateLimit(req: NextApiRequest, maxRequests = 10, windowMs = 60000) {
  const ip = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || ''
  const now = Date.now()

  if (!store.has(ip)) {
    store.set(ip, [now])
    return true
  }

  const timestamps = store.get(ip)!.filter(t => now - t < windowMs)
  timestamps.push(now)
  store.set(ip, timestamps)

  return timestamps.length <= maxRequests
}

まとめ

Next.js API Routes は、フロントエンド開発者がバックエンド機能を簡単に実装できる強力なツールです。本記事で紹介した実装パターンを活用することで、セキュアで保守性の高い API を構築できます。

実務では、以下の点を意識してください:

  • セキュリティ:入力値検証、認証、環境変数管理
  • エラーハンドリング:適切なステータスコードとエラーメッセージ
  • パフォーマンス:データベース接続の再利用、非同期処理
  • 運用:ロギング、モニタリング、レート制限

これらを念頭に置いて実装すれば、スケーラブルで信頼性の高い Web アプリケーションを開発できるはずです。

タイトルとURLをコピーしました