- Next.js
Next.js Route HandlersでAPIの作り方|CRUD・CORS実例
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期連続の黒字決算。