Next.js 16 Route Handlers 完全ガイド:REST API・Webhook・ファイルアップロード実装パターン(2026年版)

Next.js 16 の Route Handlers を実務で使い倒すための完全ガイド。REST API、Webhook 署名検証、ファイルアップロード、SSE、CORS、Edge/Node ランタイム選択まで、2026 年 7 月時点で動くコードだけで整理した。

Next.js 16 Route Handlers 完全ガイド (2026)

最終更新: 2026年7月13日

Next.js 16 の Route Handlers は、App Router 内でカスタム HTTP エンドポイントを定義するための仕組みで、app/ 配下の route.ts ファイルに Web 標準の Request/Response API を使って GET・POST・PUT・DELETE などのハンドラをエクスポートすることで REST API・Webhook・ファイルアップロード処理をまとめて実装できる。私は Pages Router 時代から pages/api を書き続けてきたが、Route Handlers に移行してからは Web 標準に近い書き味と Edge Runtime の柔軟性が思った以上に効いてきた。この記事では 2026 年 7 月時点で本番運用に投入できる実装パターンを、動くコードだけで整理する。

  • Route Handlers は app/ 配下の route.tsGET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS を named export する Web 標準ベースの API 実装機能。
  • Next.js 15 以降、Route Handlers はデフォルトで動的(非キャッシュ)扱い。キャッシュしたい場合は明示的に export const dynamic = 'force-static' または fetchcache オプションを指定する。
  • Server Actions はミューテーション中心のフォーム処理、Route Handlers は外部からアクセスされる REST API・Webhook・サードパーティ連携に使い分けるのが基本方針。
  • ファイルアップロードは request.formData()、Webhook はストリーム署名検証、CORS は OPTIONS ハンドラで解決できる。
  • Edge Runtime は起動が速いが Node.js API が使えない。DB ドライバや Node ネイティブ依存があるなら export const runtime = 'nodejs' を選ぶ。

Route Handlers とは何か

Route Handlers は App Router で app/api/users/route.ts のように配置したファイルの中から HTTP メソッド名の関数を named export することで、任意の URL に紐付いた API エンドポイントを作れる仕組みだ。Pages Router 時代の pages/api/users.ts と役割は似ているが、内部実装は Fetch API の Request/Response オブジェクトに直接置き換わっていて、Web 標準どおりに request.headers.get('authorization')Response.json({...}) を書けるようになった。この違いは Edge Runtime やサービスワーカー、Cloudflare Workers などとコードを共有しやすいという実利につながる。

ファイル配置のルールもシンプルだ。app/ ツリー内で page.tsxroute.ts は同じディレクトリに共存できない(ここは初見で必ずと言っていいほどハマるポイント)。app/api/* のような専用のセグメントを切って API を集約するのが定番構成になる。詳細は Next.js 公式ドキュメントの Route Handlers セクションを参照してほしい。

// app/api/hello/route.ts
export async function GET(request: Request) {
  const url = new URL(request.url);
  const name = url.searchParams.get('name') ?? 'world';
  return Response.json({ message: `Hello, ${name}!` });
}

Route Handlers と Server Actions の使い分け

「Server Actions があれば Route Handlers はいらないのでは?」という質問を毎月のように受ける。結論から言えば、両者は排他的ではなく、目的が違う。Server Actions はフォーム送信やクライアントコンポーネントからのミューテーションを、RPC ライクに呼び出すための機能だ。一方、Route Handlers は URL とメソッドが公開されるパブリックな HTTP エンドポイントで、モバイルアプリ・サードパーティ・cron ジョブ・Webhook・ヘルスチェックなど「Next.js の外側」から叩かれる用途に向いている。詳細な選択基準は Server Actions セキュリティ完全ガイドでも触れているので、合わせて確認してほしい。

比較項目Route HandlersServer Actions
呼び出し元ブラウザ・外部サービス・cronフォーム・Client Component
URL の公開する(RESTfulな URL)しない(POST /エンコード済みID)
HTTP メソッドGET/POST/PUT/DELETE 全て実質 POST のみ
キャッシュ手動制御(デフォルト動的)常に動的
主な用途REST API・Webhook・OG 画像生成フォーム送信・DB ミューテーション
型安全性手動で Zod など関数呼び出しなので TS で完結

私自身の運用では、社内向けのフォーム UI はほぼ Server Actions に寄せ、Stripe や GitHub のような外部システムから呼び出される部分を Route Handlers にまとめている。責務が分かれると、ログやレート制限のポリシーも別立てにできて運用が楽になる。実際、直近のプロジェクトでこの分離をやったところ、監査ログの読みやすさが段違いに向上した。

REST API・CRUD エンドポイントの実装

REST な CRUD を組む場合、app/api/posts/route.tsGETPOSTapp/api/posts/[id]/route.tsGET/PATCH/DELETE を配置するのが典型パターンだ。以下は Drizzle ORM を仮定した最小実装で、Zod で入力検証を行っている。

// app/api/posts/route.ts
import { z } from 'zod';
import { db } from '@/lib/db';
import { posts } from '@/lib/schema';
import { desc } from 'drizzle-orm';

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
});

export async function GET() {
  const rows = await db.select().from(posts).orderBy(desc(posts.createdAt)).limit(50);
  return Response.json({ posts: rows });
}

export async function POST(request: Request) {
  const json = await request.json().catch(() => null);
  const parsed = CreatePostSchema.safeParse(json);
  if (!parsed.success) {
    return Response.json(
      { error: 'validation_error', issues: parsed.error.issues },
      { status: 400 },
    );
  }
  const [row] = await db.insert(posts).values(parsed.data).returning();
  return Response.json({ post: row }, { status: 201 });
}

ポイントは request.json() が失敗する(不正な JSON や空ボディの)ケースを .catch(() => null) で握りつぶし、Zod の safeParse で一括バリデーションに載せていること。返す HTTP ステータスは 201 Created、400 Bad Request と正しく分岐する。500 は握らず throw して、Next.js 側の error ハンドラに任せるのが結果的に一番シンプルだ。

動的セグメントとクエリパラメータ

Next.js 16 では動的セグメントの params が Promise になった点に注意が必要だ。[id] のような動的ルートの引数は第二引数で受け取り、必ず await する。移行作業中に忘れて型エラーで気づく人が多いので、既存の Pages Router コードを持ってくるときはここを最初にチェックしよう。

// app/api/posts/[id]/route.ts
import { db } from '@/lib/db';
import { posts } from '@/lib/schema';
import { eq } from 'drizzle-orm';

type Ctx = { params: Promise<{ id: string }> };

export async function GET(_req: Request, { params }: Ctx) {
  const { id } = await params;
  const row = await db.query.posts.findFirst({ where: eq(posts.id, Number(id)) });
  if (!row) return new Response('Not Found', { status: 404 });
  return Response.json({ post: row });
}

export async function DELETE(_req: Request, { params }: Ctx) {
  const { id } = await params;
  await db.delete(posts).where(eq(posts.id, Number(id)));
  return new Response(null, { status: 204 });
}

クエリパラメータは new URL(request.url).searchParams から取る。数値やブール値に変換するときは Zod の z.coerce.number() を使うと、文字列を安全にキャストできる。

キャッシュと再検証の制御

Next.js 15 で GET Route Handlers のデフォルトキャッシュ挙動が変更され、以降はすべての Route Handlers がデフォルトで動的(キャッシュされない)扱いになった。これは Pages Router 時代の感覚とは真逆なので、Pages から移行する人は特に気をつけてほしい。私も最初、Vercel の請求書を見て「あれ、Function 実行回数がやたら多いな」と気づいたクチだ。

明示的にキャッシュしたい場合は、次のいずれかを使う。

// app/api/rates/route.ts
export const dynamic = 'force-static';       // 常に静的化
export const revalidate = 60;                // 60 秒ごとに ISR で再生成

export async function GET() {
  const res = await fetch('https://api.example.com/rates', {
    // fetch 自体のキャッシュも制御
    next: { revalidate: 60, tags: ['rates'] },
  });
  return Response.json(await res.json());
}

タグベースの再検証は revalidateTag('rates') をどこかの Server Action や別の Route Handler から呼ぶことで、キャッシュを即座に破棄できる。データ層のキャッシュ戦略全体は Cache Components 完全ガイドにまとめてあるので、そちらとセットで理解しておくと運用時のミスが減る。

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

Route Handlers で multipart/form-data を受ける場合は request.formData() を使う。Web 標準の File オブジェクトが取れるので、arrayBuffer() でバイナリを取得して S3 や R2 に投げる、というのが定番だ。

// app/api/uploads/route.ts
import { z } from 'zod';

const MAX_BYTES = 5 * 1024 * 1024; // 5MB
const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp']);

export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get('file');

  if (!(file instanceof File)) {
    return Response.json({ error: 'file_required' }, { status: 400 });
  }
  if (!ALLOWED.has(file.type)) {
    return Response.json({ error: 'unsupported_type' }, { status: 415 });
  }
  if (file.size > MAX_BYTES) {
    return Response.json({ error: 'file_too_large' }, { status: 413 });
  }

  const bytes = new Uint8Array(await file.arrayBuffer());
  // ここで S3/R2/Blob に putObject する
  const key = `uploads/${crypto.randomUUID()}-${file.name}`;
  // await s3.putObject({ Bucket, Key: key, Body: bytes, ContentType: file.type });

  return Response.json({ key, size: bytes.byteLength }, { status: 201 });
}

Webhook 受信と署名検証

Stripe や GitHub、Clerk などの Webhook を受ける Route Handler では、生のリクエストボディに対して HMAC 署名を検証する必要がある。ここで request.json() を呼んでしまうと後段の署名検証に使うバイト列が壊れるので、request.text() で先に文字列として読むのがコツだ。私はこれで一度本番の Webhook 検証を丸ごと落とした過去があり、それ以来テンプレ化している。

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const secret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const sig = request.headers.get('stripe-signature');
  const raw = await request.text();
  if (!sig) return new Response('missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(raw, sig, secret);
  } catch (err) {
    return new Response(`invalid signature: ${(err as Error).message}`, { status: 400 });
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      // 冪等キー = event.id を DB に保存し、重複処理を防ぐ
      break;
    }
    case 'invoice.payment_failed': {
      break;
    }
  }
  return new Response('ok', { status: 200 });
}

Webhook で必ず抑えるべき原則が 2 つある。1 つ目は署名検証、2 つ目は冪等性だ。Stripe を含む多くのプロバイダはリトライを行うので、同じ event.id が複数回届く前提で DB に受信済みイベントを記録する。詳しくは Stripe の公式 Webhook ドキュメントを参照してほしい。GitHub の Webhook 検証も基本は同じで、GitHub 公式の署名検証手順と読み比べると仕組みが体に入る。

ストリーミングレスポンスと SSE

Route Handlers は ReadableStream をそのまま返せるため、Server-Sent Events (SSE) や LLM の token streaming に自然にフィットする。以下は LLM の SSE 応答を Route Handler から返す最小例だ。

// app/api/chat/route.ts
export const runtime = 'nodejs';

export async function POST(request: Request) {
  const { prompt } = await request.json();

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      // 例: OpenAI/Anthropic SDK からトークンをストリーミング取得
      for (const token of ['Hello', ' ', 'world', '!']) {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ token })}\n\n`));
        await new Promise((r) => setTimeout(r, 100));
      }
      controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'X-Accel-Buffering': 'no',
    },
  });
}

CORS の設定方法

ブラウザから外部ドメインの Route Handler を叩く場合、プリフライトリクエストに応えるために OPTIONS ハンドラを追加する。個別のハンドラでヘッダを付ける方法と、全体を Middleware でハンドリングする方法があるが、API 特化なら個別実装のほうがログを追いやすい。

// app/api/public/route.ts
const CORS = {
  'Access-Control-Allow-Origin': 'https://app.example.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400',
};

export async function OPTIONS() {
  return new Response(null, { status: 204, headers: CORS });
}

export async function GET() {
  return Response.json({ ok: true }, { headers: CORS });
}

Access-Control-Allow-Origin: * は Cookie 付きリクエストや credentials: 'include' の呼び出しで拒否される。本番では必ずオリジンを明示する。

Edge Runtime と Node.js Runtime の選択

Route Handlers は export const runtime = 'edge' または 'nodejs' でランタイムを切り替えられる。判断基準はシンプルで、依存ライブラリと起動時間のトレードオフだ。

  • Edge Runtime: コールドスタートが速く、世界中のエッジ拠点で動く。ただし Node.js コア API(fsnetcrypto の一部)は使えない。pgmysql2 のようなドライバも動かないので、HTTP ベースの DB クライアント(Neon Serverless、Turso、Supabase JS)が前提になる。
  • Node.js Runtime: 従来の Node と同等。Prisma、Drizzle+pg、AWS SDK v3、Stripe SDK など何でも動く。コールドスタートは Edge より遅いが、Vercel Fluid Compute のおかげで実運用では大差ないことが多い。

迷ったら Node.js から始めて、明確なレイテンシ改善が見込める Route だけ Edge へ移すのが安全だ。全 API をいきなり Edge に寄せると、あとで DB ドライバの都合で戻すことになりがちなので (経験談)。

エラーハンドリングと Zod バリデーション

Route Handlers から throw された例外は 500 として返されるが、そのままだとスタックトレースが漏れるリスクがある。私は共通のエラーラッパを 1 つ用意して、Zod エラー・想定外エラー・カスタム例外を統一形式に整形している。

// app/api/_lib/handle.ts
import { ZodError } from 'zod';

export async function handle<T>(fn: () => Promise<T>): Promise<Response> {
  try {
    const data = await fn();
    return Response.json(data);
  } catch (err) {
    if (err instanceof ZodError) {
      return Response.json({ error: 'validation_error', issues: err.issues }, { status: 400 });
    }
    if (err instanceof HttpError) {
      return Response.json({ error: err.code }, { status: err.status });
    }
    console.error('unhandled', err);
    return Response.json({ error: 'internal_error' }, { status: 500 });
  }
}

export class HttpError extends Error {
  constructor(public status: number, public code: string) { super(code); }
}
// app/api/posts/route.ts
import { handle, HttpError } from '../_lib/handle';

export const POST = (request: Request) => handle(async () => {
  const body = CreatePostSchema.parse(await request.json());
  const existing = await db.query.posts.findFirst({ where: eq(posts.title, body.title) });
  if (existing) throw new HttpError(409, 'title_conflict');
  return { post: await db.insert(posts).values(body).returning() };
});

この形にしておくと、ログの一元化・エラーコードの標準化・レート制限との統合すべてが 1 箇所で済み、E2E テストも書きやすくなる。

よくある質問

Next.js 16 で pages/api はまだ使えますか?

使えます。App Router と Pages Router は共存可能で、既存の pages/api/* はそのまま動作し続けます。ただし新規機能の追加は Route Handlers 側にのみ入っているため、新規開発は App Router で書くのが 2026 年時点の推奨です。

Route Handlers はキャッシュされますか?

Next.js 15 以降、GET を含むすべての Route Handlers はデフォルトで動的(キャッシュされない)扱いになりました。静的化したい場合は export const dynamic = 'force-static'export const revalidate = 60 を明示するか、fetch()next.revalidate オプションを使ってください。

Route Handlers と Server Actions はどう使い分けますか?

公開 URL を持ち、外部から叩かれる API(モバイルアプリ、Webhook、cron)は Route Handlers。フォーム送信や Client Component からのミューテーションのように「Next.js アプリ内から呼ぶ」ものは Server Actions が適しています。両方併用しても問題ありません。

Route Handlers で CORS を設定するには?

OPTIONS ハンドラをエクスポートしてプリフライトに応答し、Access-Control-Allow-Origin などのヘッダを個別または Middleware で付与します。ワイルドカード * は認証付きリクエストで拒否されるので、本番では必ず具体的なオリジンを指定してください。

Route Handlers でファイルアップロードの上限を上げる方法は?

Vercel の Serverless Functions のペイロード上限(4.5MB)を超えるファイルは、Route Handler では受け付けられません。クライアントから直接 S3/R2/Vercel Blob に PUT する presigned URL パターンに切り替えるのが定石です。セルフホストの Node.js サーバなら next.config.jsexperimental.serverActions.bodySizeLimit は Route Handlers には影響しないため、リバースプロキシ側の client_max_body_size を上げます。

Ben Howard
著者について Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.