Next.js Route Handlers でも tRPC 風の実装がしたい
App Router で API Routes 相当の実装を施すには Route Handlers を使用します。Next.js に限らず React 実装では今後 fetch を使用したいというニーズが多くなると思うのですが、fetch は型推論がほとんどダメです。また、Client/Server で実装が散らばっているため、リクエスト・レスポンスのずれが生じる懸念があります。Route Handlers でも tRPC のような実装ができればと考え、それっぽい実装ができたので紹介します。
愚直に実装する場合
「書籍販売するサイトの詳細ページで、クリックログを送信する機能を実装する」と仮定して、詳細をみていきましょう。まず、クライアント側はこのようになります。ボタンコンポーネントを作成し、fetch を実行します。
"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 サーバーにリクエストを投げます。
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 詳細をみる
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)を指定します。input
とoutput
に zod スキーマを指定し、RequestBody(input)と ResponseBody(output)の制約を設けます(このcreateContract
関数がキモですが、詳細は最後に)これで、Client fetch と Route Handlers 実装制約ができました。
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
文字列から型定義を推論しています。
"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 スキーマを満たす必要があります。
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
関数は型制約が施されたfetch
とhandler
を返すものです。
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
同じことを考えてました。正直、Next.jsが勝手にいい感じにやってほしい。 https://github.com/raviqqe/oneRPC
すごい作り込まれてますね!
同意です。今後に期待ですかね