Next.js の Route Handlers(ルートハンドラー)とは、app フォルダーの中に route.ts を置き、GET や POST という名前の関数を export するだけで API を作れる仕組みです。Web 標準の Request と Response をそのまま使うため、特別な書き方を覚えなくても、HTTP の知識がそのまま活かせます。

入門記事の多くは「GET で JSON を返す」ところで終わっていたり、Next.js 14 以前の「GET は自動でキャッシュされる」という説明のままだったりします。この記事は Next.js 16.3.6 で実際にタスク管理の API を作り、curl とブラウザ(Playwright)で動作を確かめた結果をもとに書いています。CRUD 一式、zod による入力チェック、エラーの形の統一、CORS、Cookie、ストリーミング、ファイルアップロード、Webhook の署名検証までコピペで使えるコードを載せ、GET のキャッシュが今どう動くかもビルド結果で示します。

Route Handlers の早見表

やりたいこと 書き方(Next.js 16)
API の URL を作る app/api/tasks/route.ts → /api/tasks
メソッドごとの処理 export async function GET(request) など。GET POST PUT PATCH DELETE HEAD OPTIONS
JSON を返す return Response.json(data, { status: 201 })
クエリ文字列を読む request.nextUrl.searchParams.get("q")(NextRequest を使う)
JSON ボディを読む await request.json()
URL の一部([id])を読む const { id } = await ctx.params(Promise なので await)
params に型を付ける ctx: RouteContext<"/api/tasks/[id]">(import 不要)
入力チェック zod の schema.parse(body)
ステータスだけ返す new Response(null, { status: 204 })
定義していないメソッド 自動で 405 Method Not Allowed
CORS OPTIONS を export し、Access-Control-Allow-* を付ける
Cookie・ヘッダー await cookies() / await headers()(next/headers)
少しずつ返す new Response(new ReadableStream(...))
ファイルを受け取る await request.formData() → File
Webhook の署名検証 await request.text() で生のボディを読む
GET をキャッシュする 既定はキャッシュしない。dynamic = "force-static" や revalidate、Cache Components なら 'use cache'

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Next.js Route HandlersでAPIの作り方|CRUD・CORS実例」

動作確認の環境

この記事のコードは、次の環境で next build → next start し、curl と Playwright(Chromium)で確認しました。

項目 バージョン
Next.js 16.3.6(create-next-app@16 で作成、Turbopack)
React 19.2.8
zod 4.6.5
TypeScript 5.9.3
Node.js 22.22.2
npx create-next-app@16 api-demo --ts --app --no-tailwind --no-eslint --use-npm
cd api-demo
npm install zod

ファイルの置き場所と URL の対応

route.ts を置いたフォルダーの道のりが、そのまま API の URL になります。この記事で作るファイルは次のとおりです。

app/
└── api/
    ├── tasks/
    │   ├── route.ts          → GET /api/tasks, POST /api/tasks, OPTIONS
    │   └── [id]/
    │       └── route.ts      → GET・PATCH・DELETE /api/tasks/:id
    ├── me/route.ts           → Cookie とヘッダー
    ├── stream/route.ts       → ストリーミング
    ├── upload/route.ts       → ファイルアップロード
    └── webhook/route.ts      → Webhook 受信
lib/
├── api.ts                    → エラー形式の統一・JSON の検証
├── cors.ts                   → CORS ヘッダー
└── tasks.ts                  → サンプルデータ

同じフォルダーに page.tsx と route.ts は置けません(app/api/ の下に分けるのはこのためです)。route.ts にはレイアウトも適用されません。

エラーレスポンスの形をそろえる

API を作るときは、最初にエラーの返し方を1か所にまとめておくと、各ハンドラーが短くなり、呼ぶ側も扱いやすくなります。ここでは、すべてのエラーを { "error": { "code", "message", "details" } } の形で返します。

// lib/api.ts:エラーレスポンスの形をそろえる共通部品
import { z } from "zod";

export class ApiError extends Error {
  constructor(
    public status: number,
    public code: string,
    message: string,
    public details?: unknown,
  ) {
    super(message);
  }
}

// すべてのエラーをこの形で返す
// { "error": { "code": "...", "message": "...", "details": ... } }
export function errorResponse(status: number, code: string, message: string, details?: unknown) {
  return Response.json(
    { error: { code, message, ...(details !== undefined && { details }) } },
    { status },
  );
}

// ハンドラーを包んで、例外を統一形式のレスポンスに変える
export function withErrors<A extends unknown[]>(
  handler: (...args: A) => Promise<Response>,
) {
  return async (...args: A): Promise<Response> => {
    try {
      return await handler(...args);
    } catch (e) {
      if (e instanceof ApiError) return errorResponse(e.status, e.code, e.message, e.details);
      if (e instanceof z.ZodError)
        return errorResponse(400, "VALIDATION_ERROR", "入力内容に誤りがあります", z.flattenError(e));
      if (e instanceof SyntaxError)
        return errorResponse(400, "INVALID_JSON", "JSONの形式が正しくありません");
      console.error(e);
      return errorResponse(500, "INTERNAL_ERROR", "サーバーでエラーが発生しました");
    }
  };
}

// JSONボディを読んで zod で検証する
export async function parseJson<T extends z.ZodType>(request: Request, schema: T): Promise<z.infer<T>> {
  const body = await request.json(); // 壊れたJSONなら SyntaxError
  return schema.parse(body); // 不正なら ZodError
}

想定外のエラーはサーバーのログにだけ残し、利用者には中身を見せないようにしています。

サンプルデータは動作確認用にメモリ上に置きます(本番では DB に置き換えます)。

// lib/tasks.ts
export type Task = { id: string; title: string; done: boolean; priority: "low" | "mid" | "high"; createdAt: string };

const g = globalThis as unknown as { __tasks?: Map<string, Task>; __seq?: number };
g.__tasks ??= new Map<string, Task>([
  ["1", { id: "1", title: "備品リストを更新する", done: false, priority: "mid", createdAt: "2026-09-28T00:00:00.000Z" }],
  ["2", { id: "2", title: "週報を書く", done: true, priority: "low", createdAt: "2026-09-28T00:00:00.000Z" }],
]);
g.__seq ??= 2;

export const tasks = g.__tasks;
export const nextId = () => String((g.__seq = (g.__seq ?? 0) + 1));

一覧の取得と新規作成:GET・POST

クエリ文字列もボディも、zod のスキーマで検証してから使います。クエリ文字列はすべて文字列で届くため、z.coerce.number() で数値に変換しています。

// app/api/tasks/route.ts:一覧の取得(GET)と新規作成(POST)
import type { NextRequest } from "next/server";
import { z } from "zod";
import { tasks, nextId, type Task } from "@/lib/tasks";
import { withErrors, parseJson } from "@/lib/api";
import { corsHeaders, withCors } from "@/lib/cors";

// クエリ文字列の検証(文字列 → 数値の変換も zod に任せる)
const listQuery = z.object({
  done: z.enum(["true", "false"]).optional(),
  q: z.string().max(50).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

const createBody = z.object({
  title: z.string().trim().min(1, "タイトルは必須です").max(100),
  priority: z.enum(["low", "mid", "high"]).default("mid"),
});

// GET /api/tasks?done=false&q=備品&limit=10
export const GET = withErrors(async (request: NextRequest) => {
  const query = listQuery.parse(Object.fromEntries(request.nextUrl.searchParams));
  let items = [...tasks.values()];
  if (query.done) items = items.filter((t) => String(t.done) === query.done);
  if (query.q) items = items.filter((t) => t.title.includes(query.q!));
  return withCors(request, Response.json({ items: items.slice(0, query.limit), total: items.length }));
});

// POST /api/tasks
export const POST = withErrors(async (request: NextRequest) => {
  const input = await parseJson(request, createBody);
  const task: Task = { id: nextId(), done: false, createdAt: new Date().toISOString(), ...input };
  tasks.set(task.id, task);
  return withCors(
    request,
    Response.json(task, { status: 201, headers: { Location: `/api/tasks/${task.id}` } }),
  );
});

// プリフライト(ブラウザが本番のリクエストの前に送る確認)
export async function OPTIONS(request: NextRequest) {
  return new Response(null, { status: 204, headers: corsHeaders(request) });
}

export const GET = withErrors(...) のように、関数を変数として export しても Route Handler として認識されます。実行結果です(-i はレスポンスヘッダーも表示するオプション。Date などの行は省略)。

$ curl -s "http://localhost:7230/api/tasks?done=false&q=%E5%82%99%E5%93%81&limit=10"
{"items":[{"id":"1","title":"備品リストを更新する","done":false,"priority":"mid","createdAt":"2026-09-28T00:00:00.000Z"}],"total":1}

$ curl -s -i -X POST http://localhost:7230/api/tasks \
    -H 'Content-Type: application/json' -d '{"title":"会議室を予約する","priority":"high"}'
HTTP/1.1 201 Created
content-type: application/json
location: /api/tasks/3

{"id":"3","done":false,"createdAt":"2026-09-29T05:50:57.030Z","title":"会議室を予約する","priority":"high"}

$ curl -s -X POST http://localhost:7230/api/tasks \
    -H 'Content-Type: application/json' -d '{"title":"","priority":"urgent"}'
{"error":{"code":"VALIDATION_ERROR","message":"入力内容に誤りがあります","details":{"formErrors":[],"fieldErrors":{"title":["タイトルは必須です"],"priority":["Invalid option: expected one of \"low\"|\"mid\"|\"high\""]}}}}

$ curl -s -X POST http://localhost:7230/api/tasks -H 'Content-Type: application/json' -d '{"title":'
{"error":{"code":"INVALID_JSON","message":"JSONの形式が正しくありません"}}

作成時は 201 Created と Location ヘッダーを返すと、REST の約束ごとに沿った API になります。壊れた JSON を送った場合も、500 ではなく 400 で理由を返せています。

1件の取得・更新・削除:動的ルート [id] と await params

URL の一部を変数にするには、フォルダー名を [id] にします。第2引数の params は Next.js 15 から Promise になり、16 では同期的に読む書き方が削除されたため、必ず await します。

// app/api/tasks/[id]/route.ts:1件の取得・部分更新・削除
import type { NextRequest } from "next/server";
import { z } from "zod";
import { tasks } from "@/lib/tasks";
import { ApiError, withErrors, parseJson } from "@/lib/api";

const updateBody = z
  .object({
    title: z.string().trim().min(1).max(100),
    done: z.boolean(),
    priority: z.enum(["low", "mid", "high"]),
  })
  .partial()
  .refine((v) => Object.keys(v).length > 0, "更新する項目を1つ以上指定してください");

function findTask(id: string) {
  const task = tasks.get(id);
  if (!task) throw new ApiError(404, "NOT_FOUND", `タスク ${id} は見つかりません`);
  return task;
}

// RouteContext<'/api/tasks/[id]'> は next dev / build / typegen で自動生成される型
export const GET = withErrors(async (_req: NextRequest, ctx: RouteContext<"/api/tasks/[id]">) => {
  const { id } = await ctx.params; // params は Promise なので await する
  return Response.json(findTask(id));
});

export const PATCH = withErrors(async (request: NextRequest, ctx: RouteContext<"/api/tasks/[id]">) => {
  const { id } = await ctx.params;
  const task = findTask(id);
  const input = await parseJson(request, updateBody);
  const updated = { ...task, ...input };
  tasks.set(id, updated);
  return Response.json(updated);
});

export const DELETE = withErrors(async (_req: NextRequest, ctx: RouteContext<"/api/tasks/[id]">) => {
  const { id } = await ctx.params;
  findTask(id);
  tasks.delete(id);
  return new Response(null, { status: 204 });
});

RouteContext<"/api/tasks/[id]"> は、next dev・next build・next typegen のときに自動で作られる型で、import せずに使えます。URL の文字列から params の型({ id: string })が決まるため、フォルダー名を変えたときの書き忘れに型エラーで気づけます。

$ curl -s -i http://localhost:7230/api/tasks/99
HTTP/1.1 404 Not Found
{"error":{"code":"NOT_FOUND","message":"タスク 99 は見つかりません"}}

$ curl -s -i -X PATCH http://localhost:7230/api/tasks/3 -H 'Content-Type: application/json' -d '{"done":true}'
HTTP/1.1 200 OK
{"id":"3","done":true,"createdAt":"2026-09-29T05:50:57.030Z","title":"会議室を予約する","priority":"high"}

$ curl -s -X PATCH http://localhost:7230/api/tasks/1 -H 'Content-Type: application/json' -d '{}'
{"error":{"code":"VALIDATION_ERROR","message":"入力内容に誤りがあります","details":{"formErrors":["更新する項目を1つ以上指定してください"],"fieldErrors":{}}}}

$ curl -s -i -X DELETE http://localhost:7230/api/tasks/3
HTTP/1.1 204 No Content

PUT は全体の置き換え、PATCH は一部の変更です。一部の項目だけを送る想定なので PATCH にし、.partial() で全項目を省略可能にしています。

定義していないメソッドと OPTIONS の自動応答

export していないメソッドで呼ぶと、Next.js が自動で 405 を返します。また OPTIONS を書かなかった場合は、定義済みのメソッドを Allow ヘッダーに並べて返してくれます。

$ curl -s -i -X PUT http://localhost:7230/api/tasks/1
HTTP/1.1 405 Method Not Allowed

$ curl -s -i -X OPTIONS http://localhost:7230/api/tasks/1
HTTP/1.1 204 No Content
allow: DELETE, GET, HEAD, OPTIONS, PATCH

HEAD も自動で含まれています。ただし、自動の OPTIONS には CORS 用のヘッダーが付かないため、別のオリジン(ドメインやポートが違うサイト)のブラウザから呼ぶ場合は、次の章のように自分で書きます。

CORS:別オリジンのブラウザから呼べるようにする

CORS(オリジン間リソース共有)とは、別のオリジンのページから API を呼んでよいかを、サーバーがヘッダーでブラウザに伝える仕組みです。Content-Type: application/json の POST などは、ブラウザが先に OPTIONS の「プリフライト」を送り、許可を確かめてから本番のリクエストを送ります。

// lib/cors.ts:許可するオリジンだけに CORS ヘッダーを付ける
const ALLOWED_ORIGINS = ["http://localhost:7231", "https://app.example.com"];

export function corsHeaders(request: Request): Record<string, string> {
  const origin = request.headers.get("origin");
  if (!origin || !ALLOWED_ORIGINS.includes(origin)) return {};
  return {
    "Access-Control-Allow-Origin": origin,
    "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
    Vary: "Origin",
  };
}

// 既存のレスポンスに CORS ヘッダーを足す
export function withCors(request: Request, res: Response) {
  for (const [k, v] of Object.entries(corsHeaders(request))) res.headers.set(k, v);
  return res;
}

プリフライト(OPTIONS)と本番のレスポンス(GET・POST)の両方にヘッダーが必要です。許可するオリジンはリストで判定し、Vary: Origin を付けておきます。

実際に、ポート 7231(許可あり)と 7232(許可なし)で配信したページから、Chromium で fetch の POST を実行した結果です。

呼び出し元のオリジン 結果
http://localhost:7231 status=201、タスクが作成された
http://localhost:7232 TypeError: Failed to fetch。コンソールに下記のエラー
Access to fetch at 'http://localhost:7230/api/tasks' from origin 'http://localhost:7232' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

curl では CORS の制限がかからないため、「curl では動くのにブラウザでは動かない」ときは CORS を疑います。複数の API にまとめて設定したい場合は、next.config.ts の headers() や proxy.ts(Next.js 16 で middleware.ts から名前が変わったもの)でも設定できます。

ヘッダーと Cookie を読む・書く

next/headers の cookies() と headers() は、Next.js 16 では必ず await して使います。

// app/api/me/route.ts:ヘッダーと Cookie を読む・書く
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";

export async function GET(request: NextRequest) {
  const h = await headers();
  const cookieStore = await cookies();
  const visits = Number(cookieStore.get("visits")?.value ?? "0") + 1;

  cookieStore.set("visits", String(visits), { httpOnly: true, sameSite: "lax", path: "/", maxAge: 60 * 60 });

  return Response.json(
    {
      userAgent: h.get("user-agent"),
      lang: request.headers.get("accept-language"),
      visits,
    },
    { headers: { "X-Request-Id": crypto.randomUUID() } },
  );
}
$ curl -s -i -c cj -b cj -H 'Accept-Language: ja' http://localhost:7230/api/me
HTTP/1.1 200 OK
set-cookie: visits=1; Path=/; Expires=Tue, 29 Sep 2026 06:51:16 GMT; Max-Age=3600; HttpOnly; SameSite=lax
x-request-id: 4f178dd9-d829-4218-b752-d57381fc9f99
{"userAgent":"curl/8.5.0","lang":"ja","visits":1}

$ curl -s -c cj -b cj http://localhost:7230/api/me
{"userAgent":"curl/8.5.0","lang":null,"visits":2}

headers() は読み取り専用なので、レスポンスのヘッダーは Response の第2引数で渡します。

ストリーミングで少しずつ返す

ReadableStream を Response に渡すと、処理の途中から順にデータを送れます。生成 AI の応答や、長い処理の進み具合を返すときに使います。

// app/api/stream/route.ts:少しずつ返す(Server-Sent Events 形式)
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function GET(request: Request) {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 1; i <= 3; i++) {
        if (request.signal.aborted) break; // 接続が切れたら止める
        controller.enqueue(encoder.encode(`data: {"step":${i},"at":"${new Date().toISOString()}"}\n\n`));
        await sleep(500);
      }
      controller.enqueue(encoder.encode("data: [DONE]\n\n"));
      controller.close();
    },
  });
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no", // nginx などのバッファリングを止める
    },
  });
}

curl -N(バッファしない)で受け取り、1行ごとに受信時刻(秒)を付けた結果です。0.5秒おきに1行ずつ届いており、まとめてではなく順に送られていることがわかります。

14.91s  data: {"step":1,"at":"2026-09-29T05:51:14.897Z"}
15.41s  data: {"step":2,"at":"2026-09-29T05:51:15.398Z"}
15.90s  data: {"step":3,"at":"2026-09-29T05:51:15.901Z"}
16.40s  data: [DONE]

X-Accel-Buffering: no は、nginx などが応答をためて最後にまとめて送るのを防ぐためのヘッダーです。

ファイルアップロードを受け取る(FormData)

multipart/form-data のリクエストは await request.formData() で読み、ファイルは File として取り出せます。Pages Router の API Routes と違い、ボディを読むための追加設定は要りません。

// app/api/upload/route.ts:multipart/form-data でファイルを受け取る
import { writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { errorResponse } from "@/lib/api";

const MAX_BYTES = 2 * 1024 * 1024; // 2MB
const ALLOWED = ["image/png", "image/jpeg", "text/plain"];

export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get("file");
  const note = form.get("note"); // テキスト項目は string
  if (!(file instanceof File)) return errorResponse(400, "NO_FILE", "file 項目がありません");
  if (file.size > MAX_BYTES) return errorResponse(413, "TOO_LARGE", "2MBまでのファイルにしてください");
  if (!ALLOWED.includes(file.type)) return errorResponse(415, "UNSUPPORTED_TYPE", `${file.type} は受け付けていません`);

  const dir = path.join(process.cwd(), "uploads");
  await mkdir(dir, { recursive: true });
  const saved = `${randomUUID()}${path.extname(file.name)}`; // 利用者のファイル名はそのまま使わない
  await writeFile(path.join(dir, saved), Buffer.from(await file.arrayBuffer()));

  return Response.json({ saved, originalName: file.name, type: file.type, size: file.size, note }, { status: 201 });
}
$ curl -s -i -F "file=@memo.txt;type=text/plain" -F "note=会議メモ" http://localhost:7230/api/upload
HTTP/1.1 201 Created
{"saved":"e471ef47-68c2-478a-bec0-c5843c8261ce.txt","originalName":"memo.txt","type":"text/plain","size":5,"note":"会議メモ"}

$ curl -s -F "file=@package.json;type=application/json" http://localhost:7230/api/upload
{"error":{"code":"UNSUPPORTED_TYPE","message":"application/json は受け付けていません"}}

保存名は UUID などで作り直します(../ を含む名前で別の場所に書かれるのを防ぐため)。file.type は送る側が決められる値です。コンテナや複数台の構成では、本番はオブジェクトストレージに保存するのが一般的です。

Webhook を受け取る(生のボディで署名を検証)

外部サービスからの Webhook は、送り主を確かめるために「ボディの HMAC 署名」をヘッダーに付けてくるのが一般的です。署名は受け取ったままの文字列で計算するため、request.json() ではなく request.text() で生のボディを読みます。

// app/api/webhook/route.ts:署名つき Webhook を受け取る(生のボディで検証)
import { createHmac, timingSafeEqual } from "node:crypto";
import { errorResponse } from "@/lib/api";

export async function POST(request: Request) {
  const secret = process.env.WEBHOOK_SECRET;
  if (!secret) return errorResponse(500, "NO_SECRET", "WEBHOOK_SECRET が未設定です");

  const raw = await request.text(); // 先に json() を呼ぶと署名の計算に使う元の文字列が得られない
  const received = request.headers.get("x-signature") ?? "";
  const expected = createHmac("sha256", secret).update(raw).digest("hex");

  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return errorResponse(401, "BAD_SIGNATURE", "署名が一致しません");
  }

  const event = JSON.parse(raw) as { type: string; id: string };
  // ここで重い処理はせず、キューに積むなどしてすぐ 200 を返す
  return Response.json({ received: true, type: event.type, id: event.id });
}
$ BODY='{"type":"order.created","id":"evt_001"}'
$ SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac s3cret | awk '{print $2}')
$ curl -s -i -X POST http://localhost:7230/api/webhook -H "x-signature: $SIG" -d "$BODY"
HTTP/1.1 200 OK
{"received":true,"type":"order.created","id":"evt_001"}

$ curl -s -i -X POST http://localhost:7230/api/webhook -H "x-signature: deadbeef" -d "$BODY"
HTTP/1.1 401 Unauthorized
{"error":{"code":"BAD_SIGNATURE","message":"署名が一致しません"}}

比較には timingSafeEqual を使い、時間の差から署名を推測されにくくします。ヘッダー名や署名の形式は送り元のドキュメントに合わせます。

GET のキャッシュ:いまは既定でキャッシュされない

Next.js 15 から、Route Handlers の GET は既定でキャッシュされなくなりました(14 までは条件を満たすと静的になっていました)。16 には cacheComponents を有効にしない「従来のモデル」と、有効にする「Cache Components」の2つがあり、挙動が少し違います。同じコードを両方でビルドした結果です(○ は静的、ƒ はリクエストごとに実行)。

ルート 中身 従来のモデル(既定) Cache Components
/api/info 固定の JSON を返すだけ ƒ(毎回実行) ○(ビルド時に作成)
/api/time new Date() を返す ƒ ƒ
/api/time-static dynamic = "force-static" ○(値はビルド時で固定) ビルドエラー
/api/time-revalidate revalidate = 60 ○(Revalidate 1m) -
/api/time-cached 補助関数に 'use cache' と cacheLife("minutes") - ○(Revalidate 1m、Expire 1h)

表の「-」は、その組み合わせを試していないことを表します。従来のモデルでは、固定の JSON を返すだけの GET でも ƒ(毎回実行)でした。キャッシュしたいときだけ export const dynamic = "force-static" や export const revalidate = 60 で明示します(force-static では、2回呼んでもビルド時の値が返りました)。

Cache Components(next.config.ts に cacheComponents: true)では、dynamic などのルート設定は使えず、ビルドが止まります。実際のエラーです。

Error: Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 2 | export const dynamic = "force-static";

代わりに、キャッシュしたい処理を補助関数に切り出して 'use cache' を書きます(ハンドラーの本体には直接書けません)。

// app/api/time-cached/route.ts:'use cache' はハンドラー本体ではなく補助関数に書く
import { cacheLife } from "next/cache";

async function getSnapshot() {
  "use cache";
  cacheLife("minutes");
  return { now: new Date().toISOString() };
}

export async function GET() {
  return Response.json(await getSnapshot());
}
$ curl -s -i http://localhost:7231/api/time-cached     (2回とも同じ値)
x-nextjs-cache: HIT
cache-control: s-maxage=60, stale-while-revalidate=3540
{"now":"2026-09-29T05:53:28.751Z"}

GET 以外のメソッドはどの設定でもキャッシュされません。request.url や headers()、cookies() を読んだ GET も、リクエストごとに実行されます。

Server Actions との使い分け

自分のアプリの画面から呼ぶ更新処理は Server Actions、アプリの外から呼ばれる入口は Route Handlers、と考えると迷いにくくなります。

比べる点 Route Handlers Server Actions
呼び出す側 外部サービス、スマホアプリ、別オリジンの画面、curl 自分のアプリのフォームやボタン
URL /api/tasks のように決まった URL がある 決まった URL はない(Next.js が内部で管理)
HTTP メソッド GET・POST・PATCH・DELETE などを自由に POST のみ
返せるもの JSON、テキスト、ファイル、ストリーム、任意のステータス 値を返し、画面の再描画やリダイレクトにつなげる
型の共有 呼ぶ側で型を別に用意する 関数として import するので型がそのまま使える
キャッシュの更新 revalidatePath / revalidateTag を呼べる 同じく呼べる。updateTag は Server Actions 専用
向いている処理 公開 API、Webhook、ファイル配信、RSS 画面からの登録・更新・削除

Server Component から自分の Route Handler を fetch する必要はありません。サーバーの中で動いているので、データ取得の関数を直接呼べば、HTTP の往復がなくなります。

よくあるつまずきと対処

症状 原因 対処
params.id が undefined、または型エラー params が Promise const { id } = await ctx.params
同じ URL に画面と API を置けない 同じフォルダーの page.tsx と route.ts は衝突する(公式の規則) API は app/api/... に分ける
405 Method Not Allowed そのメソッドを export していない、関数名の大文字小文字違い GET・POST など大文字で export
壊れた JSON で 500 になる request.json() の SyntaxError を捕まえていない 共通の try/catch で 400 に変換
ブラウザだけ CORS エラー プリフライトか本番レスポンスにヘッダーがない OPTIONS と各メソッドの両方に付ける
GET の値が更新されない force-static や revalidate を付けている 設定を外す。更新時に revalidatePath
dynamic を書くとビルドエラー Cache Components では使えない 'use cache' を補助関数に書く
Webhook の署名が合わない json() で読んでから文字列に戻している request.text() の生の文字列で計算
ストリーミングが最後にまとめて届く プロキシがバッファしている X-Accel-Buffering: no、プロキシ側の設定

よくある質問

Q. NextResponse.json() と Response.json() はどちらを使えばよいですか? A. JSON を返すだけならどちらでも同じ結果になります。この記事では Web 標準の Response.json() を使っています。NextResponse は、cookies.set() をレスポンスに直接書いたり、NextResponse.redirect()・rewrite() を使ったりしたいときに便利です。

Q. Pages Router の API Routes(pages/api)から移行する必要はありますか? A. API Routes は今も使えます。App Router のプロジェクトでは Route Handlers がその置き換えにあたり、両方を同時に使う必要はありません。req.query は request.nextUrl.searchParams、res.status(201).json() は Response.json(data, { status: 201 }) のように置き換えます。

Q. 認証が必要な API はどう書けばよいですか? A. 各ハンドラーの最初で、Cookie のセッションや Authorization ヘッダーを確かめ、認証できなければ 401、権限がなければ 403 を返します。この記事の withErrors と同じように、確認処理を共通の関数にしておくと書き忘れを防げます。proxy.ts での確認は入口の振り分けに使い、データを返す直前の確認もハンドラー側で行うと安全です。

まとめ

  • app/api/.../route.ts に GET・POST などを export するだけで API になる。未定義のメソッドは自動で 405
  • 動的ルートの params は Promise。RouteContext<"/api/tasks/[id]"> で型を付けて await する
  • zod で入力を検証し、エラーは1つの形にそろえると、ハンドラーも呼ぶ側もシンプルになる
  • CORS はプリフライト(OPTIONS)と本番レスポンスの両方にヘッダーが必要。ブラウザで確認する
  • Webhook は request.text() の生のボディで署名を検証し、timingSafeEqual で比べる
  • GET は既定でキャッシュされない。従来のモデルは dynamic・revalidate、Cache Components は 'use cache' で明示する
  • 画面からの更新は Server Actions、外から呼ばれる入口は Route Handlers と使い分ける

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Next.js Route HandlersでAPIの作り方|CRUD・CORS実例」

株式会社RJCでは、API の設計のような基礎から最新のフレームワークの動きまで、実際に手を動かして確かめながら仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?