🪢

Next.js 内完結で OpenAPI が欲しいとき — ts-rest と next-rest-framework

に公開

TL;DR

  • 前回の記事は monorepo + 外部呼び出しあり の文脈で書いた。本記事は Next.js 内完結 + OpenAPI が欲しい という違うスコープの話
  • 候補は ts-restnext-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 generateopenapi.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