Zenn
⚔️

Zodと設定0行でNext.jsのRoute Handlersに完全な型を付与する最強ライブラリ「FrourioNext」

2025/02/18に公開
28

Next.js + REST APIを必要とする人のためのライブラリ

世間がRSCで盛り上がっている今でも私はREST APIを好んで使っています。OpenAPIをSwaggerUIで展開してAPI仕様書として納品できるし、保守引継ぎのためのエンジニア教育も比較的簡単です。

SwiftやKotlinでネイティブアプリ対応する場合もOpenAPIからHTTPクライアントを自動生成して使うことが多いのではないでしょうか?

ゆえにNext.jsのRoute HandlersでAPIを開発したい場面がそれなりにあるのですが、公式の方法だけだと型が緩くて辛いです。回避策として全てのリクエストをHonoに投げて型を付ける記事をよく見かけますが、ファイルベースルーティングの利点が失われてしまいます。

この記事では、aspidaとfrourioの開発経験を活かして設計されたRoute Handlers特化のTypeScriptライブラリ「FrourioNext」を紹介します。

どんな問題を解決するのか

Next.jsの公式サイトの方法でRoute Handlersに型定義すると以下のようなコードになります。

app/items/[slug]/route.ts
import { type NextRequest, NextResponse } from 'next/server';
 
export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ slug: string }> },
): Promise<NextResponse<{ value: string }>> {
  const slug = (await params).slug;
  const query = request.nextUrl.searchParams.get('foo')
  const reqBody = await request.json();
  const resBody = { value: 'hello' };

  return NextResponse.json(resBody, { status: 200 });
}

このコードにはいくつかの問題があります。

  1. { slug: string }{ slag: string }とタイポしても気付けない
  2. 子孫のエンドポイントでもPromise<{ slug: string }>の型が必要になる
  3. queryの型はstring | null
  4. queryのプロパティfooをタイポしても気付けない
  5. reqBodyの型はany
  6. ステータスコードを複数扱いたい場合にresBodyとの紐づけができない

さらに、Zodでバリデーションしようとするとこうなるでしょう。

app/items/[slug]/route.ts
import { type NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const paramsValidator = z.object({ slug: z.string() });
const queryValidator = z.string();
const reqBodyValidator = z.object({ bar: z.number() });
const resBodyValidator = z.object({ value: z.string() });

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<z.infer<typeof paramsValidator>> },
): Promise<NextResponse<z.infer<typeof resBodyValidator>>> {
  const slug = paramsValidator.parse(await params).slug;
  const query = queryValidator.parse(request.nextUrl.searchParams.get('foo'));
  const reqBody = reqBodyValidator.parse(await request.json());
  const resBody = { value: 'hello' };

  return NextResponse.json(resBodyValidator.parse(resBody), { status: 200 });
}

Zodのparseを使っていますが実務ではsafeParseでエラーを抑えて安全にステータス400 or 422を返す処理も必要です。とても辛い。

FrourioNextはZodのAPI定義から型ファイルを自動生成してくれる

FrourioNextでは「API定義」と「Route実装」を2ファイルに分けて記述します。

app/items/[slug]/frourio.ts
import type { FrourioSpec } from '@frourio/next';
import { z } from 'zod';

export const frourioSpec = {
  post: {
    param: z.string(), // 単数形のparamを使い、パス変数名slugは記述しない
    query: z.object({ foo: z.string() }),
    body: z.object({ bar: z.number() }),
    res: {
      200: { body: z.object({ value: z.string() }) }, // 4xx/5xxも定義できる
    },
  },
} satisfies FrourioSpec;
app/items/[slug]/route.ts
import { createRoute } from './frourio.server'; // frourio.server.tsは自動生成

export const { POST } = createRoute({
  post: async ({ params, query, body }) => {
    return { status: 200, body: { value: 'hello' } };
  },
});

route.tsがとてもスッキリと記述できるようになりました。FrourioNextの特徴は以下の通り。

  1. params/query/body/ステータスコード/レスポンスbody全てに型が付与
  2. ランタイムでZodが自動実行されて不正な値なら422あるいは500を返却
  3. paramsは先祖全てのパス変数の型とZodを継承
  4. GETやPOST変数のexport忘れをfrourio.server.tsが型で検知
  5. 設定0行、ランタイムに残る依存パッケージなし

最強ですね。

インストールと使い方

create-next-appなど好きな方法でいつも通りTypeScriptとNext.jsを準備してください。

$ npm install zod
$ npm install @frourio/next npm-run-all --save-dev

npm-run-allは必須ではないですが、npmコマンドを以下のように書けて便利です。

package.json
{
  "scripts": {
    "dev": "run-p dev:*",
    "dev:next": "next dev",
    "dev:frourio": "frourio-next --watch",
    "build": "frourio-next && next build"
  }
}

大量のプロセスでターミナルが読みづらくなったらnpm-run-allの代わりにnotiosが良いですよ、という宣伝。
https://zenn.dev/luma/articles/nodejs-new-cli-tool-notios

Next.jsとFrourioNextを開発モードで一緒に起動。

$ npm run dev

appsrc/appディレクトリ配下のAPIを作りたい場所でfrourio.tsという名前の空ファイルを作成してみてください。
route.tsfrourio.server.tsが自動生成され、frourio.tsにサンプルコードが記述されます。

app/api/frourio.ts
import type { FrourioSpec } from '@frourio/next';
import { z } from 'zod';

export const frourioSpec = {
  get: {
    res: { 200: { body: z.object({ value: z.string() }) } },
  },
} satisfies FrourioSpec;
app/api/route.ts
import { createRoute } from './frourio.server';

export const { GET } = createRoute({
  get: async () => {
    return { status: 200, body: { value: 'ok' } };
  },
});

以降、frourio.tsにAPI定義⇒route.tsで実装する流れで進められます。自動生成・変更されるfrourio.server.tsは人が読まない前提のファイルですが、型パズル少なめで100行前後のコード量なので目視でも安全性の確認が可能です。

雑にファイルを増やしてみます。

app/api/[slug]/frourio.ts
import type { FrourioSpec } from '@frourio/next';
import { z } from 'zod';

export const frourioSpec = {
  param: z.string(), // 単数形のparamを使い、パス変数名slugは記述しない
  get: {
    headers: z.object({ cookie: z.string().optional() }),
    query: z.object({ aa: z.string() }),
    res: {
      200: { body: z.object({ bb: z.array(z.string()) }) },
      404: { body: z.undefined() },
    },
  },
  post: {
    body: z.object({ bb: z.number() }),
    res: {
      201: {
        body: z.array(z.number()),
        headers: z.object({ 'Set-Cookie': z.string() }),
      },
    },
  },
} satisfies FrourioSpec;
app/api/[slug]/route.ts
import { createRoute } from './frourio.server';

export const { GET, POST } = createRoute({
  get: async ({ params, query }) => {
    return { status: 200, body: { bb: [params.slug, query.aa] } };
  },
  post: async ({ params, body }) => {
    return { status: 201, body: [body.bb], headers: { 'Set-Cookie': params.slug } };
  },
});

テスト方法

通常のRoute Handlersと全く同じ方法でテストできます。

tests/index.spec.ts
import { NextRequest } from 'next/server';
import { expect, test } from 'vitest';
import { GET, POST } from '../app/api/[slug]/route';

test('Route Handlers', async () => {
  const slug = 'foo';
  const query = 'bar';
  const params = Promise.resolve({ slug });
  const res1 = await GET(
    new NextRequest(`http://example.com/${slug}?aa=${query}`, { params }),
  );

  await expect(res1.json()).resolves.toEqual({ bb: [slug, query] });

  const body = { bb: 3 };
  const res2 = await POST(
    new NextRequest(`http://example.com/${slug}`, {
      method: 'POST',
      params,
      body: JSON.stringify(body),
    }),
  );

  await expect(res2.json()).resolves.toEqual([body.bb]);

  expect(res2.headers.get('Set-Cookie')).toBe(slug);
});

未実装機能

FrourioNextは最強ですが、バレンタインデーに開発着手し今日で4日目なのでまだまだ機能が足りていません。直近のSaaS開発で使うライブラリなので業務で通常必要な機能は来月末までに一通り揃える予定です。

以下、優先順で並べた予定機能です。

  1. ✅(v0.2.0対応済)Zodの型をもとにqueryとparamsのプロパティをnumberやbooleanに自動変換
  2. ✅(v0.4.0対応済)FormData req/res
  3. ✅(v0.6.0対応済)子孫ディレクトリのRoute全てに共通適用されるmiddleware
  4. ✅(v0.3.0対応済)LLM用のStreamレスポンス
  5. ✅(v0.5.0対応済)OpenAPI 3.1 JSON出力
  6. aspidaライクなHTTPクライアント生成

本当はHTTPクライアントが早めに欲しいんですが、他の機能を作ってAPI定義方法の仕様が一通り決まらないと手戻りが増えるので後回しにします。

感想

OSS開発は楽しいですが、READMEと記事を書く作業が毎回最も苦しい・・・
生成AIを使ってみたけどプロンプトが下手なのでまだ自分で書く方が早いなあという感じ。今年中には全て解決するのだろうか。

GitHubでStar押してくれると嬉しいです。
https://github.com/frouriojs/frourio-next/

28

Discussion

ログインするとコメントできます