🤝

Next.js Route Handlers でも tRPC 風の実装がしたい

2023/05/25に公開2

App Router で API Routes 相当の実装を施すには Route Handlers を使用します。Next.js に限らず React 実装では今後 fetch を使用したいというニーズが多くなると思うのですが、fetch は型推論がほとんどダメです。また、Client/Server で実装が散らばっているため、リクエスト・レスポンスのずれが生じる懸念があります。Route Handlers でも tRPC のような実装ができればと考え、それっぽい実装ができたので紹介します。

愚直に実装する場合

「書籍販売するサイトの詳細ページで、クリックログを送信する機能を実装する」と仮定して、詳細をみていきましょう。まず、クライアント側はこのようになります。ボタンコンポーネントを作成し、fetch を実行します。

app/books/[bookId]/_components/Button.tsx
"use client";

type Props = {
  bookId: string;
  logId: string;
  children: React.ReactNode;
};
export function Button({ bookId, logId, children }: Props) {
  const handleClick = async () => {
    const { ok } = await fetch(`/books/${bookId}/api`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ logId }),
    }).then(res => res.json());
    console.log(ok); // ok: any
  };
  return <button onClick={handleClick}>{children}</button>;
}

Route Handlers で POST リクエストを処理するためには、次のように実装します。postClickLog 関数は型注釈がついた fetch のラッパーで、WEB API サーバーにリクエストを投げます。

app/books/[bookId]/api/route.tsx
import { postClickLog } from "@/services/server/postClickLog";
import { NextResponse } from "next/server";

type Params = { bookId };
export async function POST(request: Request, { params }: { params: Params }) {
  const { logId } = await request.json(); // logId: any
  const { ok } = await postClickLog({ logId, bookId: params.bookId }); // ok: boolean
  return NextResponse.json({ ok }); // どんな値でも返せる
}
postClickLog 詳細をみる
services/server/postClickLog.ts
import { apiHost, handleFetchError, handleFetchResponse } from ".";

type ResponseBody = {
  ok: boolean;
};
export const postClickLog = ({
  bookId,
  logId,
}: {
  bookId: string;
  logId: string;
}): Promise<ResponseBody> =>
  fetch(apiHost(`/api/log/${bookId}/click`), {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ logId }),
  })
    .then(handleFetchResponse)
    .catch(handleFetchError);

愚直に実装すると以上のようになりますが、制約がほぼない状況であり、課題点は次の 6 つです。

  • Client fetch レスポンスが any
  • Client fetch の url がズレる可能性
  • Client fetch の body が乖離する可能性
  • Route Handlers のパスパラメーターが手書き
  • Route Handlers request.json()戻り値が any
  • Route Handlers NextResponse.json() が何を返しても怒られない

つまるところ「Client fetch と Route Handlers の双方に乖離がないよう制約が欲しい」というのがモチベーションになります。

考案内容で実装する場合

セグメントにapi/_contracts/index.tsというファイルを作ります。フォルダ構成はこんな感じになります。

.app/books/[bookId]
├── _components
│   └── Button.tsx
├── api
│   ├── _contracts
│   │   └── index.ts
│   └── route.tsx
└── page.tsx

api/_contracts/index.tsでは、次のcreateContractという関数を使用し、対応するメソッド(この例では POST)を指定します。inputoutputに zod スキーマを指定し、RequestBody(input)と ResponseBody(output)の制約を設けます(このcreateContract関数がキモですが、詳細は最後に)これで、Client fetch と Route Handlers 実装制約ができました。

app/books/[bookId]/api/_contracts/index.ts
import { createContract } from "@/lib/rpc";
import { z } from "zod";

export const post = createContract({
  method: "POST",
  path: "/books/[bookId]/api",
  input: z.object({ logId: z.string() }),
  output: z.object({ ok: z.boolean() }),
});

クライアント側はこのようになります。post.fetchの引数paramsは、API URL パスである"/books/[bookId]/api"の Dynamic Route(bookId の部分)に対応するものです。createContract関数引数のpath文字列から型定義を推論しています。

app/books/[bookId]/_components/Button.tsx
"use client";

import { post } from "../api/_contracts";

type Props = {
  bookId: string;
  logId: string;
  children: React.ReactNode;
};
export function Button({ bookId, logId, children }: Props) {
  const handleClick = async () => {
    const { ok } = await post.fetch({
      params: { bookId }, // { bookId: string } を指定しないとエラー
      input: { logId }, // { logId: string } を指定しないとエラー
    });
    console.log(`result: ${ok}`); // ok: boolean
  };
  return <button onClick={handleClick}>{children}</button>;
}

Route Handlers 側はこのようになります。post.handlerの引数{ params, input }に型推論がきいているのはもちろん、戻り値は outputに指定した zod スキーマを満たす必要があります。

app/books/[bookId]/api/route.tsx
import { postClickLog } from "@/services/server/postClickLog";
import { post } from "./_contracts";

export const POST = post.handler(async ({ params, input }) => {
  const { ok } = await postClickLog({
    logId: input.logId, // input.logId は string 型に推論される
    bookId: params.bookId, // params.bookId は string 型に推論される
  });
  return { ok }; // { ok: boolean } を返さないとエラー
});

createContract 関数の内訳

createContract 関数は型制約が施されたfetchhandlerを返すものです。

lib/rpc.ts
import { NextResponse } from "next/server";
import { z } from "zod";

type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
type PathParams<T> = T extends `${infer _Start}/[${infer Param}]${infer Rest}`
  ? { [K in Param]: string } & PathParams<Rest>
  : {};

export function createContract<
  M extends Method,
  P extends string,
  T extends z.ZodObject<any>,
  K extends z.ZodObject<any>
>({
  method,
  path,
  input,
  output,
}: {
  method: M;
  path: P;
  input: T;
  output: K;
}) {
  function handler(
    impl: (ctx: {
      input: z.infer<T>;
      request: Request;
      params: PathParams<P>;
    }) => Promise<z.infer<K>>
  ): (
    request: Request,
    ctx: { params: PathParams<P> }
  ) => Promise<NextResponse> {
    return async (request, ctx) => {
      const body = await request.json();
      input.parse(body);
      const data = await impl({ ...ctx, input: body, request });
      output.parse(data);
      return NextResponse.json(data);
    };
  }
  function createUrl(path: P, params: Record<string, string>): string {
    return path.replace(/\[([^\]]+)\]/g, (_, key) => params[key]);
  }
  function client(
    {
      input,
      params,
    }: {
      input: z.infer<T>;
      params: PathParams<P>;
    },
    init?: RequestInit | undefined
  ): Promise<z.infer<K>> {
    return fetch(createUrl(path, params), {
      headers: { "Content-Type": "application/json" },
      ...init,
      method,
      body: JSON.stringify(input),
    }).then((res) => res.json());
  }
  return {
    fetch: client,
    handler,
  };
}

さっき出来たばかりなので、詳細ツメがあまいところがありそうです(query に対応できてないとことか)Next.js + zod だけで実装できる、お手軽 tRPC 風実装でした。

Discussion

TakepepeTakepepe

すごい作り込まれてますね!

正直、Next.jsが勝手にいい感じにやってほしい。

同意です。今後に期待ですかね