Route Handlers: Next.jsでAPIを書く
1. 概要
Next.jsのApp Routerでは、APIを持つために別途Expressサーバーを構築する必要はありません。Route Handlersを使用することで、appフォルダ内で直接HTTPエンドポイントを記述でき、標準的なWeb APIであるRequestとResponseを使用します。
この記事では、小規模な注文(Order)管理APIを構築します。HTTPメソッドの定義、クエリ文字列とbodyの読み込み、dynamic segmentの処理、headers/cookiesの操作、キャッシング制御、Vitestを使用したテスト作成の方法を学びます。例はNext.js 15+とTypeScriptを使用しています。
2. 最初のRoute Handler
Route Handlerはappフォルダ内に配置されたroute.ts(またはroute.js)という名前のファイルです。フォルダのパスがURLを決定します。各HTTPメソッドは大文字で命名されたexportされた関数です。
まず、シンプルなインメモリの「ストア」が必要です:
// lib/orders.ts
export type Order = {
id: string;
customer: string;
total: number;
};
const orders: Order[] = [
{ id: "1", customer: "An", total: 250_000 },
{ id: "2", customer: "Bình", total: 1_200_000 },
];
export const orderStore = {
findAll: () => [...orders],
findById: (id: string) => orders.find((o) => o.id === id),
create: (data: Omit<Order, "id">): Order => {
const order = { id: String(orders.length + 1), ...data };
orders.push(order);
return order;
},
remove: (id: string) => {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return false;
orders.splice(index, 1);
return true;
},
};
次に、GET /api/ordersエンドポイントを作成します:
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET() {
return Response.json(orderStore.findAll());
}
説明:
- ファイルが
app/api/orders/route.tsにあるため、URLは/api/ordersになります。apiというプレフィックスは単なる慣例です。Next.jsではこれを強制していません。 export async function GET()はGETリクエストを処理します。exportしていないメソッドに対しては、Next.jsが自動的に405 Method Not Allowedを返します。Response.json(...)は標準的なWeb APIです。データをシリアライズし、Content-Type: application/jsonヘッダーを設定します。
npm run devを実行して試してみましょう:
curl http://localhost:3000/api/orders
[{"id":"1","customer":"An","total":250000},{"id":"2","customer":"Bình","total":1200000}]
注意: 同じフォルダに
route.tsとpage.tsxの両方を持つことはできません。たとえば、app/orders/page.tsxとapp/orders/route.tsは競合します。そのため、APIは通常app/apiにまとめられます。
3. クエリ文字列とbodyの読み込み
3.1. クエリ文字列
ハンドラーのパラメータは標準的なRequestです。クエリ文字列を読み込むには、request.urlからURLを作成します:
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const minTotal = Number(searchParams.get("minTotal") ?? 0);
const result = orderStore.findAll().filter((o) => o.total >= minTotal);
return Response.json(result);
}
/api/orders?minTotal=500000を呼び出すと、Bìnhの注文のみが返されます:
[{"id":"2","customer":"Bình","total":1200000}]
必要に応じて、パラメータをNextRequest(next/serverから)として宣言し、request.nextUrl.searchParamsを使用できます。ここでは標準的なRequestを使用しています。これはテストで簡単に作成できるためです。
3.2. JSONのbodyとバリデーション
クライアントから送信されたデータを信頼しないでください。zodを使用してbodyを検証します。ライブラリをインストールしましょう:
npm install zod
同じファイルにPOSTハンドラーを追加します:
// app/api/orders/route.ts (補足)
import { z } from "zod";
const CreateOrderSchema = z.object({
customer: z.string().min(1),
total: z.number().positive(),
});
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "無効なJSON形式です" }, { status: 400 });
}
const parsed = CreateOrderSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: "データが無効です", issues: parsed.error.issues },
{ status: 422 },
);
}
const order = orderStore.create(parsed.data);
return Response.json(order, {
status: 201,
headers: { Location: `/api/orders/${order.id}` },
});
}
説明:
request.json()は、bodyが空であるか構文が正しくない場合、例外をthrowします。そのため、try/catchでラップして400を返します。safeParseは例外をthrowしません。{ success, data }または{ success, error }を返します。型が正しくないデータは422を受け取ります。Response.jsonの第2引数により、statusとheadersを設定できます。ここでは201 CreatedとLocationヘッダーを返しています。
curl -i -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"customer":"Chi","total":99000}'
HTTP/1.1 201 Created
location: /api/orders/3
content-type: application/json
{"id":"3","customer":"Chi","total":99000}
HTMLフォームの場合はawait request.formData()を使用します。生のデータの場合はawait request.text()を使用します。bodyは1回だけ読み込むことができることに注意してください。
4. Dynamicセグメント
GET /api/orders/:idを持つには、角括弧内に名前を持つフォルダを作成します:
// app/api/orders/[id]/route.ts
import { orderStore } from "@/lib/orders";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Context) {
const { id } = await params;
const order = orderStore.findById(id);
if (!order) {
return Response.json({ error: `注文 ${id} が見つかりません` }, { status: 404 });
}
return Response.json(order);
}
export async function DELETE(_request: Request, { params }: Context) {
const { id } = await params;
const removed = orderStore.remove(id);
return new Response(null, { status: removed ? 204 : 404 });
}
説明:
- 第2引数に
paramsが含まれます。Next.js 15以降、paramsはPromiseであるため、awaitする必要があります。 DELETEはbodyがnullの204 No Contentを返します。204はbodyを持つことができないため、この場合はResponse.jsonを使用しないでください。params内の値は常にstringです。IDが数値の場合は、自分で変換して検証する必要があります。
キャッチオール[...slug](string[]を受け取る)またはオプショナルキャッチオール[[...slug]]も使用できます。
注意:
awaitなしでparams.idを記述する古いコードは、Next.js 15+で警告またはエラーが発生します。Next.js 14からアップグレードするときは、これが最も一般的な問題です。
5. Headers、cookies、リダイレクト
request.headersからheadersを直接読み込むことができます。さらに、Next.jsはnext/headersからcookies()とheaders()関数を提供します。両方ともasync関数です。
// app/api/me/route.ts
import { cookies, headers } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const headerList = await headers();
const session = cookieStore.get("session")?.value;
if (!session) {
return Response.json({ error: "ログインしていません" }, { status: 401 });
}
return Response.json({
session,
userAgent: headerList.get("user-agent"),
});
}
export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete("session");
return new Response(null, { status: 204 });
}
cookieStore.get(...)はcookieを読み込むために使用されます。setとdeleteはRoute HandlerまたはServer Actionでのみ機能し、Server Componentでは機能しません。cookies()またはheaders()を使用すると、ルートがdynamicになり、各リクエストでレンダリングされることを意味します。
リダイレクトが必要な場合は、Response.redirect(new URL("/login", request.url), 307)またはnext/navigationのredirect()関数を使用します。
CORSの場合は、header自分で設定し、preflightのためのOPTIONSをexportする必要があります:
// app/api/public/route.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "https://shop.example.com",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
return Response.json({ status: "ok" }, { headers: corsHeaders });
}
6. キャッシング
これは多くの人が混乱する部分です。Next.js 15以降、GETハンドラーはデフォルトでキャッシュされなくなります。各リクエストは関数を再実行します。POST、PUT、DELETEなどの他のメソッドはキャッシュされません。
データがほとんど変わらず、buildで事前に静的化したい場合は、route segmentの設定でopt-inしてください:
// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // 最大で1時間ごとに再生成
export async function GET() {
const categories = ["電子機器", "書籍", "ファッション"];
return Response.json({ categories, generatedAt: new Date().toISOString() });
}
dynamic = "force-static"はbuild時にrouteを強制的にレンダリングします。その後、結果は静的ファイルとして提供されます。revalidate = 3600により、Next.jsは最大で1時間ごとにresponseを再生成できます(ISR)。next devを実行中は、routeは各リクエストで実行されます。generatedAtが同じままになるのを見るには、next build && next startを実行する必要があります。
注意:
force-staticルートがcookies()、headers()、またはクエリ文字列を読み込む場合、これらの値はbuild時に空になります。ユーザーに依存するエンドポイントに対して静的を使用しないでください。
7. VitestでRoute Handlerをテストする
Route HandlerはRequestを受け取りResponseを返すasync関数に過ぎないため、サーバーを起動することなく直接呼び出すことができます。Vitestをインストールしましょう:
npm install -D vitest vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: { environment: "node" },
});
プラグインvite-tsconfig-pathsは、テスト内でエイリアス@/lib/...が機能するのに役立ちます。テストを書きましょう:
// app/api/orders/route.test.ts
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";
function jsonRequest(body: unknown) {
return new Request("http://localhost/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("/api/orders", () => {
it("minTotalでフィルタリング", async () => {
const res = await GET(new Request("http://localhost/api/orders?minTotal=500000"));
const data = await res.json();
expect(res.status).toBe(200);
expect(data.every((o: { total: number }) => o.total >= 500_000)).toBe(true);
});
it("有効な注文を作成して201を返す", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: 150_000 }));
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toMatch(/^\/api\/orders\/\d+$/);
expect(await res.json()).toMatchObject({ customer: "Dũng", total: 150_000 });
});
it("負の合計を拒否して422を返す", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: -1 }));
expect(res.status).toBe(422);
});
});
npx vitest run
✓ app/api/orders/route.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
ハンドラーがnext/headersからcookies()またはheaders()を使用している場合、Next.jsのリクエストコンテキストがないため、直接呼び出すとthrowされます。その場合、2つの選択肢があります。vi.mock("next/headers", ...)を使用してモジュールをモック化するか、request.headersから値を読み込んでコードをテストしやすくするかです。
8. よくあるエラーと使用しないべき場合
いくつかの一般的なエラー:
- Server ComponentからRoute Handlerをフェッチする。 Server Component内で
fetch("/api/orders")を呼び出すと、余計なHTTPラウンドが生じます。build時には、サーバーがまだ実行していないため、この呼び出しは失敗します。代わりに、orderStore.findAll()またはデータアクセスレイヤーを直接呼び出してください。 - 内部フォームのmutationにRoute Handlerを使用する。 アプリケーション自体のUIだけが呼び出す場合、Server Actionsの方がシンプルです。type-safe end-to-endであり、
revalidatePathが組み込まれています。 - モジュール変数にstateを保存する(上記の
orderStoreのように)serverlessにデプロイする場合。各インスタンスは独立したメモリを持ち、いつでも再起動する可能性があります。この記事の例は説明用です。実際にはデータベースを使用する必要があります。 - bodyは1回だけ読み込むことができることを忘れている。 2回読み込む必要がある場合は、
request.clone()を使用してください。 - 構造化されていないエラーを返す。 キャッチされない例外は
500になり、HTMLエラーページが表示されます。常に明確なstatusを持つJSONを返してください。
Route Handlerは以下の場合に適しています:
- 外部クライアント(モバイルアプリまたはパートナーなど)のためのエンドポイント。
- Stripeまたはgitubからなど、データを受け取るwebhook。
- ファイル、RSS、またはストリーミングデータを返すエンドポイント。
- クライアント側のコードが
fetchで呼び出すエンドポイント。
9. まとめ
Route Handlersは各route.tsファイルをHTTPエンドポイントに変え、標準的なWebRequest/Responseを使用します。この記事では、以下を学びました:
GET、POST、DELETEメソッドの定義。- クエリ文字列とbodyの読み込み、
zodでのbodyの検証。 - Promiseの形式の
paramsを持つdynamic segmentの処理。 - headers、cookies、CORSの操作。
- 必要に応じて静的キャッシングを有効にする。
- サーバーなしでVitestを使用してハンドラーを直接テストする。
覚えておくべき主な原則:Route Handlerを公開APIまたはwebhookに使用してください。内部データについては、Server Component内で直接クエリしてください。アプリケーション自体のフォームからのmutationについては、Server Actionsを使用してください。