Article

Next.js Route Handlers実装でBFFパターンを構築する方法

高田晃太郎
Next.js Route Handlers実装でBFFパターンを構築する方法

State of JS 2023の調査では、Next.jsは回答者の中で使用経験が半数超に達し、継続利用意向も80%台と高水準だった¹。フロントエンドはもはやUIだけではなく、API設計やデータ取得戦略を含む総合設計が成果を左右する局面にある。Next.js 13/14で導入・強化されたRoute Handlersは、その流れに対する実装解として、BFF(Backend for Frontend)をアプリケーション直下で完結できる点が特徴だ³。クライアントから外部APIへ直接リクエストしていた構成をRoute Handlers経由に置き換えると、往復回数の削減とペイロード最適化によりTTFB(初期応答時間)が数十%規模で短縮されることもある。依存関係のボトルネックを避けつつUIに最適化されたデータを提供するために、BFFをNext.jsでどう設計し、どのように堅牢に運用するかを、コードとともに具体的に示す。

BFFにRoute Handlersが適している理由と設計の勘所

BFFはクライアント体験に合わせてデータ形状と集約を最適化する中間層だ。APIゲートウェイや汎用バックエンドに対して、画面単位の都合を優先できるため、過剰取得やN+1リクエストを抑えられる。BFFパターンはモバイルや複数クライアントでの要求の違いを吸収し、変更速度を高めるアプローチとして実践されてきた²。Next.jsのRoute Handlersはapp配下にAPIを共存させ、サーバー専用コードをUIと同じリポジトリとデプロイ単位で管理できる³。これによりドメインモジュールを共用しつつ、境界づけられたコンテキストごとにBFFのエンドポイントを定義しやすい。開発体験の面では型とユーティリティを共有でき、ビルドとデプロイは一元化される。運用面では同じパイプラインでロギングとメトリクスを仕込めるため、障害解析の往復も減る。重要なのは境界の引き方であり、Route Handlers内部ではUI都合の集約や権限判定に徹し、ビジネスルールの真正はドメイン層の関数に委譲する。こうするとBFFが肥大化しても関心の分離が保たれ、テストも単体と結合の層で切り分けやすい。

アーキテクチャ上の配置と依存方向

app/api以下に画面単位やドメイン単位でディレクトリを切り、入出力のコントラクト(APIの型定義)をTypeScriptの型で明示する。UIからは常にBFFを叩き、BFFは外部SaaSや社内サービスに対してフェデレーション的に集約する。依存方向はUI→BFF→ドメイン→外部の一方向を維持し、ドメインは副作用を持たない純関数と外部I/Oポートに分けると保守しやすい。キャッシュや再検証はBFFで制御し、UIはデータ鮮度を気にせずレンダリングに集中する。Next.jsのfetchキャッシュや再検証・タグ機能をBFF層で統合すると、UIは仕組みを意識せず一貫したデータ取得が可能になる⁴。

セキュリティと認可の要点

認証はクッキーやヘッダーからのトークン検証をBFFで行い、UIには機密情報を出さない。スコープベースの権限はBFFで短絡評価し、外部APIへの委譲前に絞り込みを行う。CSRF対策はクッキー運用時にSameSiteやトークン二重送信パターンを活用する¹¹。Route Handlersはサーバー実行のため秘密鍵やサービスアカウントを安全に扱え、署名や監査ヘッダー付与も一箇所で完結できる。なお、Edge Runtimeを選ぶ場合はNode.js依存のライブラリが使えないため、Web標準API中心で実装する⁵。

実装ガイド:Route HandlersでBFFを書く

まずは読み取り系のGETから始めると安全だ。外部APIのキャッシュ戦略とタグ付けをBFF側で決め、UIはSWRやReact Cacheとの整合を気にせずに済むようにする。以下はシンプルな集約GETの例で、タグ付きの再検証を有効化している⁴。

// app/api/todos/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const url = new URL(request.url);
  const page = url.searchParams.get('page') ?? '1';

  const res = await fetch(`https://jsonplaceholder.typicode.com/todos?_page=${page}`,
    { next: { revalidate: 60, tags: ['todos'] } }
  );

  if (!res.ok) {
    return NextResponse.json({ error: 'Upstream error' }, { status: 502 });
  }

  const data = await res.json();
  return NextResponse.json({ items: data, page }, {
    headers: { 'Cache-Control': 's-maxage=60, stale-while-revalidate=300' }
  });
}

書き込みを伴うPOSTは入力検証とエラー分類が肝になる。zodでスキーマを定義し、上流のレスポンスに応じてステータスを明確に返す。成功時にキャッシュタグを無効化して、一覧の鮮度を担保する⁴。

// app/api/todos/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';

const TodoInput = z.object({
  title: z.string().min(1),
  completed: z.boolean().optional()
});

export async function POST(req: Request) {
  const body = await req.json().catch(() => undefined);
  const parsed = TodoInput.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: 'ValidationError', details: parsed.error.flatten() }, { status: 400 });
  }
  try {
    const upstream = await fetch('https://api.example.com/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(parsed.data),
      cache: 'no-store'
    });
    if (!upstream.ok) {
      return NextResponse.json({ error: 'UpstreamError', status: upstream.status }, { status: 502 });
    }
    const created = await upstream.json();
    revalidateTag('todos');
    return NextResponse.json({ ok: true, item: created }, { status: 201 });
  } catch (e) {
    return NextResponse.json({ error: 'TimeoutOrNetwork' }, { status: 504 });
  }
}

認証をBFFに寄せると、クライアントはセキュアなトークンの扱いから解放される。以下はクッキーからJWTを検証し、ロールに応じて処理を分岐させる例だ。Edge Runtimeを使わない限り、joseなどのNode向けライブラリをそのまま利用できる⁵。

// app/api/me/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify, JWTPayload } from 'jose';

function getKey() {
  const secret = process.env.JWT_SECRET;
  if (!secret) throw new Error('JWT_SECRET not set');
  return new TextEncoder().encode(secret);
}

export async function GET() {
  const cookie = cookies().get('session');
  if (!cookie) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  try {
    const { payload } = await jwtVerify(cookie.value, getKey());
    const roles = (payload as JWTPayload & { roles?: string[] }).roles ?? [];
    if (!roles.includes('user')) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }
    return NextResponse.json({ id: payload.sub, roles });
  } catch {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
}

集約の強みを活かすには、複数の上流を並列に叩きつつ、ユーザー体験に合わせたタイムアウトで打ち切るのが有効だ。AbortControllerで全体のSLA(合意した応答時間)を守り、応答時間をヘッダーに埋め込んで観測可能にする⁷⁶。

// app/api/summary/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const ac = new AbortController();
  const timeout = setTimeout(() => ac.abort(), 1500);
  const t0 = performance.now();

  try {
    const [profile, stats] = await Promise.all([
      fetch('https://api.example.com/profile', { signal: ac.signal }).then(r => r.json()),
      fetch('https://api.example.com/stats', { signal: ac.signal }).then(r => r.json())
    ]);
    const dur = performance.now() - t0;
    return NextResponse.json({ profile, stats }, {
      headers: { 'Server-Timing': `aggregate;dur=${dur.toFixed(1)}` }
    });
  } catch {
    return NextResponse.json({ error: 'PartialTimeout' }, { status: 504 });
  } finally {
    clearTimeout(timeout);
  }
}

ストリーミングもBFFで完結できる。Server-Sent Events(SSE)形式で漸進的な結果を返せば、UIは状態を逐次更新できる。生成AIや長時間集計に適している⁸。

// app/api/stream/route.ts
import { NextResponse } from 'next/server';

export const runtime = 'nodejs';

export async function GET() {
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      const enc = new TextEncoder();
      let i = 0;
      const timer = setInterval(() => {
        controller.enqueue(enc.encode(`data: step-${i++}\n\n`));
        if (i > 5) {
          clearInterval(timer);
          controller.close();
        }
      }, 200);
    }
  });
  return new NextResponse(stream, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' }
  });
}

エッジ配信が向く軽量エンドポイントはEdge Runtimeで実行すると遅延をさらに削れる。ただしNode依存のライブラリは使えないため、実装は標準Web APIに寄せる⁵。

// app/api/geo/route.ts
import { NextResponse } from 'next/server';

export const runtime = 'edge';

export async function GET(req: Request) {
  const ip = (req.headers.get('x-forwarded-for') ?? '').split(',')[0] || '0.0.0.0';
  const res = await fetch(`https://api.example.com/geo?ip=${ip}`, { cache: 'no-store' });
  const data = await res.json();
  return NextResponse.json({ ip, region: data.region });
}

運用の要:レート制限、観測性、テスト

BFFはプロダクトの出入口になるため、悪用耐性と診断可能性が品質を左右する。レート制限はミドルウェアで共通実装する手もあるが、エンドポイントごとに閾値やキー定義を調整するならRoute Handlers内で完結するのが素直だ。以下はRedisを用いた単純な固定窓のレート制限の例で、IPとパスでキーを切り、閾値を超えたアクセスには429を返す。

// app/api/report/route.ts
import { NextResponse } from 'next/server';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

export async function GET(req: Request) {
  const ip = (req.headers.get('x-forwarded-for') ?? '').split(',')[0] || 'unknown';
  const key = `rl:report:${ip}`;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, 60);
  if (count > 60) {
    return NextResponse.json({ error: 'TooManyRequests' }, { status: 429 });
  }
  const res = await fetch('https://api.example.com/report');
  const data = await res.json();
  return NextResponse.json({ data });
}

観測性はログだけでなくメトリクスとトレースまで含めて一気通貫にするのが効果的だ。Server-Timingヘッダーで重要区間の所要時間を明示すれば、ブラウザやAPMでの相関が取りやすくなる⁶。上の集約例のように計測点を少なくとも上流呼び出しと全体の2箇所に置き、p95やエラー率といったSLOをダッシュボードで定点観測すると改善の打ち手が見えやすい。Next.jsのinstrumentationフックでOpenTelemetryエクスポートを有効化すれば、分散トレースも併用できる⁹。

テストは型で守りつつ、ハンドラを関数として直接呼び出す形での単体テストと、モックを外した結合テストを使い分けると良い。以下はVitestでGETハンドラを直接呼び出す簡潔な例だ。ハンドラを薄く保ち、副作用は注入可能にしておくとテストが崩れにくい。Route Handlersは標準のRequest/Responseを受け取るため、こうした呼び出しパターンが取りやすい¹⁰。

// tests/api.ping.test.ts
import { describe, it, expect } from 'vitest';
import { GET as ping } from '../app/api/geo/route';

describe('geo route', () => {
  it('returns region with 200', async () => {
    const req = new Request('http://localhost/api/geo', { headers: { 'x-forwarded-for': '1.2.3.4' } });
    const res = await ping(req);
    expect(res.status).toBe(200);
  });
});

導入効果の測定と移行戦略

BFFをRoute Handlersで立てる目的は、UI体験の一貫性と変更速度の向上にある。最初は読み取り系の一画面を対象に、既存のクライアント直アクセスをBFF経由に切り替える小さな実験から始めるのが現実的だ。契約の型をUIと共有し、メトリクスではTTFB、p95レスポンス、エラー率、ペイロードサイズ、外部API呼び出し数を観測する。ケースによっては、BFFでフィールドを画面専用に絞るだけでもJSONサイズが縮み、SWRの再検証が軽くなることでTTFBも相乗的に短縮される。書き込み系は二重書き込み期間を短く設け、BFF側で影響範囲を限定したフェイルセーフを備えると安全だ。チーム編成の観点では、ドメインモジュールの所有とBFFの所有を分け、変更要求の受け口をBFFに寄せるとリードタイムが短くなる。ROIは、フロントとバックの調整に費やす待ち時間をどれだけ削減できたかを基準に試算すると、週次の割り込み削減やスプリントの前倒し分が稼働率に直結する。導入初期はBFFが一時的に厚くなりがちだが、ドメイン層への抽出を継続すると長期の保守コストは落ち着く。

キャッシュ戦略と整合性の落としどころ

Next.jsのfetchはrevalidateやtagを使ってキャッシュ粒度を柔軟に制御できる。読み取り系はタグを積極的に用い、書き込み時にrevalidateTagを呼ぶ運用がシンプルだ⁴。整合性要件が厳しい箇所はno-storeで明示する。UIはBFFの更新タイミングを意識せず、結果整合の前提でUXを設計する。これによりパフォーマンスと一貫性のバランスが取りやすい。

失敗前提の設計とフォールトバジェット

外部依存が多いほど、タイムアウトとリトライ、サーキットブレーカの設定が効いてくる。Route HandlersではAbortControllerで明確に打ち切り⁷、ハンドラ内では失敗を画面文脈に適した形に正規化して返す。フォールトバジェット内での意図的な打ち切りは、ユーザー体験を守る最小の妥協として有効だ。Server-Timingで可視化しておけば、しきい値の再設定も科学的に行える⁶。

まとめ:小さく始めて、計測で伸ばす

BFFをNext.jsのRoute Handlersで構築すると、UIに最適化されたデータ提供、認可の集約、キャッシュと再検証の一元管理、そして観測性の強化が同じデプロイ単位で完結する³⁴。読み取りの一画面から導入し、計測を基に書き込みや集約、ストリーミングへと適用範囲を広げる道筋が現実解だ。いまボトルネックになっている待ち時間はどこか、計測値で語れる準備は整っているかを自問し、まずは一つのRoute HandlerでBFFの価値を確かめてほしい。小さな成功が積み重なれば、プロダクト全体の変更速度と体験品質は確実に底上げされていく。

参考文献

  1. State of JavaScript 2023 — Meta-Frameworks: Next.js. https://2023.stateofjs.com/en/libraries/meta-frameworks/
  2. ThoughtWorks. The BFF pattern at SoundCloud. https://www.thoughtworks.com/insights/blog/bff-soundcloud
  3. Next.js Docs — Route Handlers. https://nextjs.org/docs/app/building-your-application/routing/route-handlers
  4. Next.js Docs — revalidateTag and Cache Tags. https://nextjs.org/docs/app/api-reference/functions/revalidateTag
  5. Next.js Docs — Edge and Node.js Runtimes. https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes
  6. MDN Web Docs — Server-Timing. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing
  7. MDN Web Docs — AbortController. https://developer.mozilla.org/en-US/docs/Web/API/AbortController
  8. MDN Web Docs — Server-sent events. https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
  9. Next.js Docs — Instrumentation (OpenTelemetry). https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
  10. GitHub — next.js discussions: Testing Route Handlers in Next.js 13+. https://github.com/vercel/next.js/discussions/48427
  11. MDN Web Docs — SameSite cookies and CSRF defenses. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value