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を使用してください。

nextjsroute-handlersapitypescriptapp-router