POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSXFacebook
Nikita Dmitriev

本記事は、原著者の許諾のもとに翻訳・掲載しております。

想像してみてください。あなたは夢のNext.jsアプリケーションを構築し、ネイティブのfetch関数を使って楽しくAPIコールを行っています。全てが順調に見えましたが、アプリケーションを本番環境にデプロイした途端、事態は一変します。反復的なエラー処理のコードに溺れ、コンポーネントのあちこちに散らばった認証トークンと格闘し、なぜ一部のリクエストがサーバーでは成功するのにクライアントでは失敗するのか、そのデバッグに髪をかきむしる日々……。

聞き覚えがありませんか?あなただけではありません。Next.jsはfetchを強力なキャッシュ機能や再検証機能で拡張していますが、中心的な課題は残っています。本番環境に対応した堅牢なAPIレイヤーを構築するには、素のfetchコールだけでは不十分なのです。

本記事では、あなたのAPIレイヤーを頭痛の種から、扱うのが楽しくなるものへと変える、本番環境対応のfetchラッパーを作成します。その構築方法だけでなく、なぜそれぞれの決定が重要なのか、そしてそれがNext.js特有の実行モデルにどう適合するのかを掘り下げていきましょう。

素のfetchが抱える問題点(Next.jsでも)

まず、多くの開発者が最初にfetchに出会ったときに書くコードを見てみましょう。

// ナイーブなアプローチ - 本番環境では使わないでください!
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user;
}

これは一見無害に見えますが、時を刻む時限爆弾です。APIが404を返すと、次のようになります。

// エラーレスポンスからJSONをパースしようとすると、エラーがthrowされます
const user = await getUser('nonexistent-id'); // 💥 ドカン!

根本的な問題は、fetchHTTPのエラーステータスを例外として扱わない点にあります。404、500、その他のエラーステータスも、fetchにとっては「成功した」リクエストと見なされます。fetchがrejectするのはネットワークエラーの場合のみです。そのため、Next.jsを使っている場合でも、手動でresponse.okをチェックし、適切にエラーを処理する必要があります。

しかし、これはほんの始まりに過ぎません。実際のアプリケーションでは、以下のような事柄も処理する必要があります。

  • 保護されたルートのための認証トークン
  • アプリ全体で一貫したエラーハンドリング
  • リクエスト/レスポンスの変換(JSONの自動パースなど)
  • ローディング状態とエラーバウンダリ
  • 型安全性のためのTypeScriptサポート
  • Next.jsにおけるサーバーとクライアントのコンテキストによる挙動の違い

これら全ての問題をエレガントに解決するラッパーを構築していきましょう。

Fetchラッパーのアーキテクチャ設計

コードに入る前に、理想的なラッパーが提供すべきものを考えてみましょう。

  1. HTTPステータスコードの自動エラーハンドリング
  2. 適切なエラー処理を備えた組み込みのJSONパース
  3. 認証トークンの管理
  4. 適切な型付けによるTypeScriptサポート
  5. Next.jsのコンテキスト認識(サーバー vs. クライアント)
  6. さまざまな環境に対応できる拡張可能な設定
  7. 自然に使える一貫したAPI

このラッパーを、退屈でエラーを起こしやすいタスクを全て処理しつつ、クリーンで予測可能なインターフェースを提供してくれる、親切なアシスタントだと考えてください。

基盤の構築:ラッパーのコア構造

まずは基本的な構造から始めましょう。Next.jsの慣習に従い、lib/api.tsというファイルを作成します。

// lib/api.ts
interface ApiConfig {
  baseUrl?: string;
  defaultHeaders?: Record<string, string>;
  timeout?: number;
}

interface ApiResponse<T = any> {
  data: T;
  status: number;
  headers: Headers;
}

class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public response?: Response
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

class ApiClient {
  private config: Required<ApiConfig>;

  constructor(config: ApiConfig = {}) {
    this.config = {
      baseUrl: config.baseUrl || '',
      defaultHeaders: {
        'Content-Type': 'application/json',
        ...config.defaultHeaders,
      },
      timeout: config.timeout || 10000,
    };
  }

  private async makeRequest<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<ApiResponse<T>> {
    // 次にこれを実装します
  }
}

なぜクラスベースのアプローチなのか? Reactでは関数型アプローチが人気ですが、クラスにはいくつかの利点があります。

  • 状態管理: 設定や、場合によってはキャッシュデータを保存できます。
  • メソッドチェーン: 流れるようなAPIメソッドを後からでも追加できます。
  • 継承: チームは特定のユースケースのためにベースクラスを拡張できます。
  • カプセル化: privateメソッドで実装の詳細を隠蔽できます。

ラッパーの心臓部:リクエストロジック

では、コアとなるリクエストロジックを実装しましょう。ここが肝心な部分です。

// lib/api.ts (続き)
class ApiClient {
  // ... 前のコード

  private async makeRequest<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<ApiResponse<T>> {
    const url = this.buildUrl(endpoint);
    const requestOptions = this.buildRequestOptions(options);

    try {
      // タイムアウト用のAbortControllerを作成
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);

      const response = await fetch(url, {
        ...requestOptions,
        signal: controller.signal,
      });

      clearTimeout(timeoutId);

      // ここがキーポイントです。ステータスをチェックし、エラーの場合はthrowします
      if (!response.ok) {
        throw new ApiError(
          `HTTP ${response.status}: ${response.statusText}`,
          response.status,
          response
        );
      }

      // JSONを安全にパース
      const data = await this.parseResponse<T>(response);

      return {
        data,
        status: response.status,
        headers: response.headers,
      };

    } catch (error) {
      if (error.name === 'AbortError') {
        throw new ApiError('Request timeout', 408);
      }
      throw error;
    }
  }

  private buildUrl(endpoint: string): string {
    // 絶対URLと相対URLの両方を処理
    if (endpoint.startsWith('http')) {
      return endpoint;
    }
    return `${this.config.baseUrl}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
  }

  private buildRequestOptions(options: RequestInit): RequestInit {
    return {
      ...options,
      headers: {
        ...this.config.defaultHeaders,
        ...options.headers,
      },
    };
  }

  private async parseResponse<T>(response: Response): Promise<T> {
    const contentType = response.headers.get('content-type');

    if (contentType?.includes('application/json')) {
      try {
        return await response.json();
      } catch (error) {
        throw new ApiError('Invalid JSON response', response.status, response);
      }
    }

    // テキストレスポンスを処理
    return (await response.text()) as unknown as T;
  }
}

ここでの重要な点は、fetchの挙動をより直感的なものに変換していることです。response.okをチェックし、HTTPエラーに対してエラーをthrowすることで、アプリケーション全体でエラーハンドリングが予測可能で一貫したものになります。

HTTPメソッドの便利なラッパーメソッドを追加する

次に、開発者が実際に使用するメソッドを追加しましょう。

// lib/api.ts (続き)
class ApiClient {
  // ... 前のコード

  async get<T>(endpoint: string, options?: RequestInit): Promise<ApiResponse<T>> {
    return this.makeRequest<T>(endpoint, { ...options, method: 'GET' });
  }

  async post<T>(
    endpoint: string,
    data?: any,
    options?: RequestInit
  ): Promise<ApiResponse<T>> {
    return this.makeRequest<T>(endpoint, {
      ...options,
      method: 'POST',
      body: data ? JSON.stringify(data) : undefined,
    });
  }

  async put<T>(
    endpoint: string,
    data?: any,
    options?: RequestInit
  ): Promise<ApiResponse<T>> {
    return this.makeRequest<T>(endpoint, {
      ...options,
      method: 'PUT',
      body: data ? JSON.stringify(data) : undefined,
    });
  }

  async delete<T>(endpoint: string, options?: RequestInit): Promise<ApiResponse<T>> {
    return this.makeRequest<T>(endpoint, { ...options, method: 'DELETE' });
  }

  async patch<T>(
    endpoint: string,
    data?: any,
    options?: RequestInit
  ): Promise<ApiResponse<T>> {
    return this.makeRequest<T>(endpoint, {
      ...options,
      method: 'PATCH',
      body: data ? JSON.stringify(data) : undefined,
    });
  }
}

POST、PUT、PATCHリクエストのデータを自動的にJSON.stringifyしている点に注目してください。これにより、リクエストボディのstringifyを忘れるという、もう一つのよくあるバグの原因を排除します。

認証:トークン管理の課題

Next.jsでは認証が面白くなるところです。従来のSPAとは異なり、サーバーサイドとクライアントサイドの両方のコンテキストを考慮する必要があります。認証サポートを追加しましょう。

// lib/api.ts (続き)
interface AuthConfig {
  tokenProvider?: () => Promise<string | null> | string | null;
  tokenHeader?: string;
  tokenPrefix?: string;
}

class ApiClient {
  private authConfig: AuthConfig;

  constructor(config: ApiConfig = {}, authConfig: AuthConfig = {}) {
    // ... 前のコンストラクタのコード
    this.authConfig = {
      tokenHeader: 'Authorization',
      tokenPrefix: 'Bearer',
      ...authConfig,
    };
  }

  private async buildRequestOptions(options: RequestInit): Promise<RequestInit> {
    const headers = { ...this.config.defaultHeaders };

    // 利用可能な場合は認証トークンを追加
    if (this.authConfig.tokenProvider) {
      const token = await this.authConfig.tokenProvider();
      if (token) {
        headers[this.authConfig.tokenHeader!] =
          `${this.authConfig.tokenPrefix} ${token}`;
      }
    }

    return {
      ...options,
      headers: {
        ...headers,
        ...options.headers,
      },
    };
  }

  // makeRequestを更新して、非同期のbuildRequestOptionsを使用するようにする
  private async makeRequest<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<ApiResponse<T>> {
    const url = this.buildUrl(endpoint);
    const requestOptions = await this.buildRequestOptions(options);

    // ... メソッドの残りの部分は同じ
  }
}

ここではトークンプロバイダーパターンが非常に重要です。トークンを直接保存する代わりに、それを取得できる関数を提供します。これにより、以下のことが可能になります。

  • さまざまなソース(localStorage、Cookie、メモリ)から新しいトークンをフェッチする
  • トークン更新ロジックを透過的に処理する
  • 異なるストレージメカニズムを持つサーバーとクライアントの両方のコンテキストで動作する

コンテキストを意識したAPIインスタンスの作成

ここからがNext.jsならではの魔法の出番です。実行コンテキストごとに異なる設定が必要です。

// lib/api.ts (続き)

// クライアントサイドのトークンプロバイダー(ブラウザのみ)
const getClientToken = (): string | null => {
  if (typeof window === 'undefined') return null;
  return localStorage.getItem('auth_token');
};

// サーバーサイドのトークンプロバイダー(SSR用)
const getServerToken = (): string | null => {
  // サーバーコンポーネントでは、Cookieからトークンを取得することがあります
  // これは簡略化された例です - 実際にはNext.jsのheaders()を使用します
  return null;
};

// コンテキストごとに異なるインスタンスを作成
export const clientApi = new ApiClient(
  {
    baseUrl: process.env.NEXT_PUBLIC_API_URL || '/api',
  },
  {
    tokenProvider: getClientToken,
  }
);

export const serverApi = new ApiClient(
  {
    baseUrl: process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api',
  },
  {
    tokenProvider: getServerToken,
  }
);

// 利便性のために、コンテキストを意識したAPIをエクスポート
export const api = typeof window === 'undefined' ? serverApi : clientApi;

これは非常に画期的なアプローチです。コンテキストを意識したインスタンスを作成することで、サーバーとクライアントで異なる挙動をさせながら、どこでも同じAPIインターフェースを使用できます。サーバーインスタンスは絶対URLとサーバーサイド認証を使用し、クライアントインスタンスは相対URLとブラウザストレージを使用するといった具合です。

TypeScript:型安全にする

ラッパーをさらに堅牢にするために、適切なTypeScriptサポートを追加しましょう。

// lib/api.ts - TypeScriptインターフェースの追加
interface User {
  id: string;
  name: string;
  email: string;
}

interface ApiEndpoints {
  // APIの形状を定義
  '/users': {
    GET: { data: User[] };
    POST: { body: Omit<User, 'id'>; data: User };
  };
  '/users/:id': {
    GET: { data: User };
    PUT: { body: Partial<User>; data: User };
    DELETE: { data: { success: boolean } };
  };
}

// 型安全なラッパーメソッド
class TypedApiClient extends ApiClient {
  async getTyped<T extends keyof ApiEndpoints, M extends keyof ApiEndpoints[T]>(
    endpoint: T,
    method: M extends 'GET' ? 'GET' : never
  ): Promise<ApiEndpoints[T][M] extends { data: infer D } ? D : never> {
    const response = await this.get(endpoint as string);
    return response.data;
  }

  // POST、PUT、DELETEなどについても同様のメソッド...
}

これにより複雑さは増しますが、素晴らしい開発者体験を提供します。IDEがエンドポイントをオートコンプリートし、タイプミスを検知し、正しいペイロードの型を渡していることを保証してくれます。

Next.jsコンポーネントでの使用方法

では、実際のNext.jsのシナリオで、このラッパーがどのように輝くかを見てみましょう。

サーバーコンポーネントでの使用例

// app/users/page.tsx - サーバーコンポーネント
import { serverApi } from '@/lib/api';

interface User {
  id: string;
  name: string;
  email: string;
}

export default async function UsersPage() {
  try {
    const { data: users } = await serverApi.get<User[]>('/users');

    return (
      <div>
        <h1>Users</h1>
        {users.map(user => (
          <div key={user.id}>{user.name}</div>
        ))}
      </div>
    );
  } catch (error) {
    if (error instanceof ApiError) {
      return <div>Error: {error.message}</div>;
    }
    return <div>Something went wrong</div>;
  }
}

Reactフックを使用したクライアントコンポーネント

// components/UserProfile.tsx - クライアントコンポーネント
'use client';

import { useState, useEffect } from 'react';
import { clientApi, ApiError } from '@/lib/api';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserProfileProps {
  userId: string;
}

export function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const { data } = await clientApi.get<User>(`/users/${userId}`);
        setUser(data);
      } catch (err) {
        if (err instanceof ApiError) {
          setError(err.message);
        } else {
          setError('An unexpected error occurred');
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUser();
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

ルートハンドラでの使用例

// app/api/users/route.ts - APIルートハンドラ
import { NextRequest } from 'next/server';
import { serverApi } from '@/lib/api';

export async function GET(request: NextRequest) {
  try {
    // 外部APIにリクエストを転送
    const { data } = await serverApi.get('/external-api/users');
    return Response.json(data);
  } catch (error) {
    if (error instanceof ApiError) {
      return Response.json(
        { error: error.message },
        { status: error.status }
      );
    }
    return Response.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

高度な機能:キャッシュとリクエストの重複排除

本番アプリケーションでは、キャッシュやリクエストの重複排除を追加したくなるかもしれません。

// lib/api.ts - 高度な機能
class ApiClient {
  private cache = new Map<string, { data: any; timestamp: number }>();
  private pendingRequests = new Map<string, Promise<any>>();

  async get<T>(
    endpoint: string,
    options?: RequestInit & { cache?: boolean; cacheTTL?: number }
  ): Promise<ApiResponse<T>> {
    const cacheKey = `GET:${endpoint}`;
    const now = Date.now();

    // まずキャッシュをチェック
    if (options?.cache) {
      const cached = this.cache.get(cacheKey);
      const ttl = options.cacheTTL || 60000; // デフォルトは1分

      if (cached && (now - cached.timestamp) < ttl) {
        return { data: cached.data, status: 200, headers: new Headers() };
      }
    }

    // 同時リクエストを重複排除
    if (this.pendingRequests.has(cacheKey)) {
      return this.pendingRequests.get(cacheKey)!;
    }

    const requestPromise = this.makeRequest<T>(endpoint, { ...options, method: 'GET' });
    this.pendingRequests.set(cacheKey, requestPromise);

    try {
      const response = await requestPromise;

      // 成功したレスポンスをキャッシュ
      if (options?.cache && response.status === 200) {
        this.cache.set(cacheKey, { data: response.data, timestamp: now });
      }

      return response;
    } finally {
      this.pendingRequests.delete(cacheKey);
    }
  }
}

エラーハンドリング戦略

このラッパーの最大の利点の一つは、エラーハンドリングを一元化できることです。以下にその活用方法を示します。

// lib/error-handler.ts
import { ApiError } from './api';

export function handleApiError(error: unknown): string {
  if (error instanceof ApiError) {
    switch (error.status) {
      case 401:
        // ログインにリダイレクトするか、トークンを更新
        return '続行するにはログインしてください';
      case 403:
        return 'この操作を実行する権限がありません';
      case 404:
        return '要求されたリソースが見つかりませんでした';
      case 500:
        return 'サーバーエラーです。後でもう一度お試しください';
      default:
        return error.message;
    }
  }

  return '予期しないエラーが発生しました';
}

// コンポーネントでの使用例
import { handleApiError } from '@/lib/error-handler';

try {
  await api.get('/protected-resource');
} catch (error) {
  const errorMessage = handleApiError(error);
  toast.error(errorMessage);
}

ファイル構造と整理

APIレイヤーを整理するため、以下のようなファイル構造をお勧めします。

lib/
├── api/
│   ├── index.ts          # メインのAPIクライアントとエクスポート
│   ├── types.ts          # TypeScriptインターフェース
│   ├── endpoints.ts      # エンドポイントの定義
│   └── error-handler.ts  # エラーハンドリングユーティリティ
├── hooks/
│   ├── useApi.ts         # カスタムReactフック
│   └── useAuth.ts        # 認証フック
└── utils/
    └── api-helpers.ts    # ユーティリティ関数

この構造により、APIレイヤーが整理され、チームメンバーが必要なものを簡単に見つけられるようになります。

ベストプラクティスとよくある落とし穴

推奨事項:

  • 常にエラーを明示的に処理する - 捕捉されないまま放置しない
  • リクエスト/レスポンスの形状にTypeScriptインターフェースを使用する
  • 異なる環境やサービスごとに別のインスタンスを作成する
  • コンポーネントに適切なローディング状態を実装する
  • ネットワークリクエストを減らすため、必要に応じてレスポンスをキャッシュする

非推奨事項:

  • 本番アプリケーションで機密性の高いトークンをlocalStorageに保存しない(httpOnly Cookieを使用する)
  • HTTPステータスコードを無視しない - 異なるステータスを適切に処理する
  • レンダーメソッドでAPIコールを行わない - useEffectまたはサーバーコンポーネントを使用する
  • リクエストタイムアウトを忘れない - ハングするリクエストを防ぐ
  • URLをハードコードしない - 環境変数を使用する

よくある落とし穴:

  1. 「私のマシンでは動くのに」という罠: 常に異なるNext.jsのコンテキスト(サーバー、クライアント、APIルート)でテストする。
  2. トークン更新地獄: 必要になる前に、適切なトークン更新ロジックを実装する。
  3. エラーバウンダリの軽視: APIを利用するコンポーネントをエラーバウンダリでラップする。

大局的に見る:なぜこれが重要なのか

堅牢なfetchラッパーを構築することは、単にコードをクリーンにすることだけが目的ではありません。アプリケーションのための信頼性の高い基盤を築くことが重要です。APIレイヤーがしっかりしていると、以下のことが可能になります。

  • ネットワーク問題のデバッグではなく、機能開発に集中できる
  • リクエスト処理の一貫性が保たれているため、自信を持ってスケールできる
  • 明確で文書化されたAPIインターフェースにより、新しい開発者のオンボーディングが迅速になる
  • 一元化されたエラーハンドリングと型付けにより、より良いコード品質を維持できる

これは未来の自分への投資だと考えてください。このラッパーの構築に費やす1時間は、本番環境の問題をデバッグしたり、反復的なエラー処理コードを書いたり、ネットワーク関連のバグを探したりする何十時間もの時間を節約してくれるでしょう。

結論:APIレイヤーを競争上の優位性として

私たちは、素のfetchコールの質素な始まりから、認証、エラー、TypeScriptの安全性、そしてNext.js特有の実行コンテキストを処理する、洗練された本番環境対応のAPIクライアントへと旅をしてきました。しかし、重要なのは、これは単により良いコードを書くことだけではないということです。 今日の開発環境において、APIレイヤーの品質は、チームの開発速度とアプリケーションの信頼性に直接影響します。巧みに作られたfetchラッパーは、チームが迅速に動き、自信を持って製品を届けられるようにする、目に見えないインフラとなるのです。 私たちが探求してきたパターン(コンテキスト認識、適切なエラーハンドリング、TypeScriptの統合、そして考え抜かれたアーキテクチャ)は、単なる「あれば良いもの」ではありません。それらは、本番環境で壊れやすい脆弱なアプリケーションと、現実世界の事象を適切に処理する堅牢なシステムの分かれ目となるのです。

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。