Next.js 内完結で OpenAPI が欲しいとき — ts-rest と next-rest-framework
TL;DR
- 前回の記事は monorepo + 外部呼び出しあり の文脈で書いた。本記事は Next.js 内完結 + OpenAPI が欲しい という違うスコープの話
- 候補は
ts-restとnext-rest-frameworkの 2 つ - 設計思想が真逆:contract を別ファイルに集約するか、route ファイルに co-locate するか
- co-locate して docs 自動生成を最重視 →
next-rest-framework - 将来 Next.js から離れる可能性・contract を独立資産にしたい →
ts-rest
| ts-rest | next-rest-framework | |
|---|---|---|
| schema の置き場所 | 別ファイル(contract) | route.ts に inline |
| 対応ランタイム | Next.js / Express / Fastify / Nest など | Next.js 専用 |
| validator | Zod / Standard Schema(Valibot, Arktype 等) | Zod |
| OpenAPI 生成 |
@ts-rest/open-api で生成 |
フレームワーク本体が生成 |
| client 提供 |
@ts-rest/react-query 等の公式 client |
OpenAPI から生成(or TypedNextResponse) |
| App Router 対応 | ✅ | ✅(Pages Router も) |
前提:このスコープに絞った理由
前回記事の選択フローを再掲する。
OpenAPI 不要・Next.js 内完結 → tRPC
OpenAPI 必要・外部公開 / monorepo → ts-rest
OpenAPI spec が先にある → Hey API / Orval
この中で曖昧なまま残っていたのが「Next.js 内完結だが OpenAPI が欲しい」というケース。なぜそういう要求が出るかというと:
-
AI Agent から API を叩きたい —
AGENTS.mdに openapi.yaml へのパスを書くだけで Claude Code などが endpoint を把握できる - Mobile(Expo / Capacitor)から呼ぶ可能性がある — Server Action は使えないので REST + 型付き client が要る
- 社内ドキュメントとして残したい — エンドポイント一覧を手書きしたくない
このとき tRPC は外せる(OpenAPI を出すのが本筋ではない)。残るのが ts-rest と next-rest-framework になる。
設計思想の違い
両者の依存関係を図にすると、形が真逆になる。
ts-rest は contract が起点で、route も client も openapi.yaml もすべてそこから派生する。schema は packages/schemas/ や app/_contracts/ のような独立した場所に集約される。
next-rest-framework は route ファイルが起点で、schema は route と同じファイルに書く。openapi.yaml はその副産物として生成される。
書き心地の比較
1. ts-rest
contract を独立ファイルで定義する。
// contracts/tools.ts
import { initContract } from "@ts-rest/core";
import { z } from "zod";
const c = initContract();
export const toolContract = c.router({
list: {
method: "GET",
path: "/api/tools",
responses: { 200: z.array(ToolSchema) },
},
create: {
method: "POST",
path: "/api/tools",
body: CreateToolSchema,
responses: { 201: ToolSchema },
},
});
App Router での handler:
// app/api/tools/route.ts
import { createNextHandler } from "@ts-rest/serverless/next";
import { toolContract } from "@/contracts/tools";
const handler = createNextHandler(
toolContract,
{
list: async () => ({ status: 200, body: await db.tools.findMany() }),
create: async ({ body }) => ({ status: 201, body: await db.tools.create({ data: body }) }),
},
{ handlerType: "app-router" }
);
export { handler as GET, handler as POST };
クライアント:
import { initClient } from "@ts-rest/core";
import { toolContract } from "@/contracts/tools";
const client = initClient(toolContract, { baseUrl: "" });
const { body } = await client.list();
contract が単一の真実の源になる。schema の場所を探すのに迷わない。
2. next-rest-framework
route ファイルに inline で書く。
// app/api/tools/route.ts
import { route, routeOperation, TypedNextResponse } from "next-rest-framework";
import { z } from "zod";
export const { GET, POST } = route({
listTools: routeOperation({ method: "GET" })
.outputs([{ status: 200, contentType: "application/json", body: z.array(ToolSchema) }])
.handler(async () => {
const tools = await db.tools.findMany();
return TypedNextResponse.json(tools, { status: 200 });
}),
createTool: routeOperation({ method: "POST" })
.input({ contentType: "application/json", body: CreateToolSchema })
.outputs([{ status: 201, contentType: "application/json", body: ToolSchema }])
.handler(async (req) => {
const body = await req.json();
const tool = await db.tools.create({ data: body });
return TypedNextResponse.json(tool, { status: 201 });
}),
});
schema を別ファイルに切り出す必要はない。route.ts を開けばその endpoint の全情報が揃っている。
OpenAPI は npx next-rest-framework generate で openapi.json が出る。
どちらを選ぶか
3 つの軸で判断できる。
軸 1:schema の置き場所の好み
| 好み | 向く |
|---|---|
| 1 ファイルで完結させたい | next-rest-framework |
| 別ファイルで集約管理したい | ts-rest |
これは技術的優劣ではなく、コードベースの読み方の好みの問題。Next.js の Server Action や Server Component と並べたとき、co-locate の方が一貫している、と感じる人もいれば、schema は独立資産として扱いたい、と感じる人もいる。
軸 2:将来 Next.js から離れる可能性
ts-rest の contract は Next.js に依存しない。Express や Fastify、Hono への移植が可能。一方 next-rest-framework は Next.js Route Handler に密結合しており、移植するには書き直しが要る。
「将来絶対に Next.js から出ない」と言い切れるなら、この差は無視できる。逆に「数年後に Edge runtime や別ランタイムに動かす可能性がある」なら ts-rest の方が安全側に倒れる。
軸 3:client 側の体験
- ts-rest:
@ts-rest/react-queryで React Query 統合が公式に提供されている。TanStack Query を使っているなら自然に繋がる - next-rest-framework: OpenAPI を起点に Hey API / Orval などで client を生成する流れになる
React Query との統合が決め手になるなら ts-rest が一歩リード。素の fetch でいいなら差は小さい。
実際に同じ endpoint を両方で書いてみたら出た差分
最小構成の Next.js 16 + Zod 4.4 で、Todo の GET / POST /api/todos を両方で実装して観察した。
1. インストール — peer dependency
# ts-rest
npm install @ts-rest/core@rc @ts-rest/serverless@rc zod
# → ERESOLVE: peer next@"^12.0.0 || ^13.0.0 || ^14.0.0" from @ts-rest/serverless
# Next.js 15 / 16 と衝突するため --legacy-peer-deps 必須
# next-rest-framework
npm install next-rest-framework zod
# → 問題なくインストールできる
ts-rest(rc を含む)の @ts-rest/serverless は peer dependency が ^12 || ^13 || ^14 のまま。Next.js 15 / 16 では --legacy-peer-deps か --force で押し通すしかない。動作はする。
2. OpenAPI 生成 — Zod 4 で罠が分かれる
両方で OpenAPI を吐かせてみたら、対照的な結果が出た。
ts-rest:デフォルトだと schema が空 {} で出力される
import { generateOpenApi } from "@ts-rest/open-api";
import { todoContract } from "../contracts/todos";
const document = generateOpenApi(todoContract, {
info: { title: "Todos API", version: "1.0.0" },
});
出力:
{
"openapi": "3.0.2",
"paths": {
"/api/todos": {
"get": {
"responses": {
"200": { "content": { "application/json": { "schema": {} } } }
}
}
}
}
}
schema が全部 {}。これは Issue #840 — Bug(@ts-rest/open-api): no longer return schema with Zod4 として報告済み。closed だが「schemaTransformer を渡してください」という workaround 提示で閉じられている。
回避するには Zod 4 の z.toJSONSchema を呼ぶ自前 transformer を渡す:
const document = generateOpenApi(
todoContract,
{ info: { title: "Todos API", version: "1.0.0" } },
{
schemaTransformer: ({ schema }) => {
if (schema && typeof schema === "object" && "_zod" in schema) {
return z.toJSONSchema(schema as z.ZodType, { unrepresentable: "any" }) as any;
}
return null;
},
}
);
これで { "type": "string", "minLength": 1 } のように constraints まで保持された OpenAPI が出る。「Zod 4 対応済み」と紹介されている情報を鵜呑みにすると、OpenAPI を出した瞬間に schema が空で出てくるので注意。
next-rest-framework:generate コマンドが docs handler を要求する
$ npx next-rest-framework generate
Next REST Framework config not found. Initialize a docs handler to generate the OpenAPI spec.
エラーメッセージが親切ではないが、docsRoute() を route として置くことが暗黙の前提になっている:
// app/api/openapi.json/route.ts
import { docsRoute } from "next-rest-framework";
export const { GET } = docsRoute({
openApiJsonPath: "/api/openapi.json",
docsConfig: { provider: "swagger-ui", title: "Todos API" },
});
これを置いた上で再度 generate すると、今度は:
Error while generating the API spec: ENOENT: no such file or directory,
open '.../public/api/openapi.json'
出力先の public/api/ ディレクトリを勝手に作ってくれない。mkdir -p public/api してから再実行。3 回試行錯誤してようやく出る。
ただし出てくる OpenAPI は質が高い。$ref で components 化までしてくれる:
{
"components": {
"schemas": {
"CreateTodoRequestBody": {
"type": "object",
"properties": { "title": { "type": "string" } },
"required": ["title"]
}
}
}
}
注意:z.string().min(1) の .min(1) は minLength: 1 に変換されない。基本的な shape(type, properties, required)は出るが、refinement は落ちる。validation 自体はサーバー側で効くので動作には影響しないが、OpenAPI 経由で生成した client / mock では制約が伝わらない。
原因を追ったところ、next-rest-framework が Zod 4.1.13 を bundle 同梱しており、host に install された別バージョンの Zod(4.4.3 等)で構築された schema を bundle 内部の JSONSchemaGenerator から処理すると schema._zod.bag が空に見えてしまう(クロスリアルム問題)。issue として報告した: Issue #222
3. 書き心地 — schema の所在
ts-rest は contract が独立しているので「endpoint 一覧を一望したい」とき contract ファイル一つ開けば全部見える。逆に「この endpoint の handler はどこ?」となったときは contract と route の双方を行き来する必要がある。
next-rest-framework は route.ts に schema と handler が同居しているので、endpoint 単位での読み書きは速い。逆に「全 endpoint を一望したい」場合は OpenAPI を生成して見るか、route ファイルを横断的に grep する必要がある。
monorepo + 複数 client の構成では前者の利点が大きく、Next.js 単体アプリでは後者の方が摩擦が少ない。
4. まとめ — 観察された差分
| 観点 | ts-rest 3.53-rc | next-rest-framework 6.1 |
|---|---|---|
| Next.js 15 / 16 への peer 追従 | ❌ ^12-14、--legacy-peer-deps 必須 |
✅ |
| OpenAPI 生成のデフォルト出力 | ❌ Zod 4 だと schema 空 | △ docs handler + public/api/ 必要、出れば $ref 化される |
Zod 4 の refinement(.min() 等) |
✅ 自前 transformer なら保持 | ❌ shape だけで refinement 落ちる(Issue #222 報告済み) |
| contract の置き場所 | 別ファイル(packages/ に集約しやすい) | route と同居 |
結論
| 状況 | 選ぶもの |
|---|---|
| Next.js 内完結、OpenAPI 不要 | tRPC(前回記事の通り) |
| Next.js 内完結、OpenAPI 必要、co-locate 派 | next-rest-framework |
| Next.js 内完結、OpenAPI 必要、contract 分離派 | ts-rest(ただし Zod 4 利用時は自前 transformer 必須) |
| monorepo + 外部呼び出しあり | ts-rest(前回記事の通り) |
前回記事の「ts-rest 推し」は monorepo 文脈の話であって、Next.js 内完結であれば next-rest-framework も対等な候補になる。
ただし 2026 年 6 月時点では両方とも Zod 4 対応に穴がある:
- ts-rest は OpenAPI 生成のデフォルト transformer が Zod 3 のままで、自前 transformer 必須
- next-rest-framework は Zod 4 を受け取れるが、
.min()等の refinement が OpenAPI に落ちない
「Zod 4 で書いた validation がそのまま OpenAPI にも反映される」状態を期待していると、どちらでも一手間かかる。Zod 3 のままなら ts-rest はデフォルトで動く(next-rest-framework もここに関しては Zod 3 の方が refinement 含めて完全に出る可能性が高い)。新規プロジェクトで Zod 4 を採用するなら、この罠は事前に織り込んでおく。
この記事は Vottia のプロダクト開発の中で得た知見をまとめたものです。
Discussion