🔥

今ホットなHonoを使ってNext.jsのRoute Handlersをハイジャックする

2024/04/22に公開1

はじめに 🚩

この記事では、Next.js の Route Handlers の代わりに Hono を使って、API ルートを置き換える方法について説明します。
Hono の RPC 機能を使って、Next.js の API ルートを置き換えることで、API のエンドポイントの URL や戻り値の型を自動で取得し、堅牢的なコードを書くことができます。

実装例 📝

簡単な記事投稿デモを作成し、Hono を使って API ルートを置き換える方法を説明します。

前準備

Next.js プロジェクトを作成し、先日 GA された Neon Database を使ってデータベースを作成します。さらに、Drizzle ORM を使ってデータベースにアクセスし、投稿データを取得・作成するための API ルートを作成します。

Next.js プロジェクトに Neon Database と Drizzle ORM を導入する方法については、以下の公式ドキュメントを参照してください。

https://orm.drizzle.team/learn/tutorials/drizzle-nextjs-neon

db/schema.ts ファイルを作成し、投稿データのスキーマを定義します。

db/schema.ts
import {
  timestamp,
  pgTable,
  text,
} from 'drizzle-orm/pg-core';

export const posts = pgTable('post', {
  id: text('id').notNull().primaryKey(),
  text: text('text').notNull(),
  createdAt: timestamp('createdAt', { mode: 'date' }).notNull().defaultNow(),
});

generate コマンドを使って、Drizzle によって生成されたデータベースのスキーマを作成し、migrate コマンドを使ってデータベースに適用します。
セットアップは以上です。

Hono の導入

app ディレクトリ内に api ディレクトリを新たに作り、そこに optional Catch-all Segment を使って API ルートを作成します。
optional Catch-all Segment を使うことで、様々な HTTP メソッドやパスパラメータを活用した複数の API エンドポイントを、一つのファイルで手軽に管理できるようになります。

route.ts ファイルを作成し、API ルートの型定義を行います。

app/api/[[...route]]/route.ts
import { Hono } from "hono"
import { handle } from "hono/vercel"

import posts from "./posts"

export const runtime = "edge"

// basePath は API ルートのベースパスを指定します
// 以降、新たに生やす API ルートはこのパスを基準に追加されます
const app = new Hono().basePath("/api")
const route = app.route("/posts", posts)

export type AppType = typeof route

export const GET = handle(app)
export const POST = handle(app)

同階層に posts.ts ファイルを作成し、Drizzle を使ってデータベースにアクセスし、投稿データを取得・作成するための API ルートを作成します。

app/api/[[...route]]/posts.ts
import { db } from "@/db/drizzle"
import { posts } from "@/db/schema"
import { zValidator } from "@hono/zod-validator"
import { createId } from "@paralleldrive/cuid2"
import { Hono } from "hono"
import { z } from "zod"

export const schema = z.object({
  text: z.string().min(1, "Please write something."),
})

const app = new Hono()
  .get("/", async (c) => {
    const posts = await db.query.posts.findMany({
      orderBy: (posts, { desc }) => [desc(posts.createdAt)],
    })

    return c.json(posts)
  })
  .post("/", zValidator("form", schema), async (c) => {
    const data = c.req.valid("form")

    const post = await db
      .insert(posts)
      .values({
        id: createId(),
        text: data.text,
      })
      .returning()

    return c.json(post)
  })

export default app

GET リクエスト

  .get("/", async (c) => {
    const posts = await db.query.posts.findMany({
      orderBy: (posts, { desc }) => [desc(posts.createdAt)],
    })

    return c.json(posts)
  })

Hono では、リクエストを処理する際に Context オブジェクトを引数 c として使用します。この Context オブジェクトにより、リクエストの詳細情報へのアクセスやレスポンスの構築が可能になります。パスパラメータやクエリパラメータの取得、ヘッダーの追加、ステータスコードの設定など、レスポンスをテキスト、JSON、HTML 形式で返すことができます。

今回 GET リクエストでは、データベースから全ての投稿を取得し、それらをクライアントに JSON 形式で返却します。
findMany メソッドを利用して投稿一覧を取得し、orderBy を用いて投稿を作成日時の降順に並べ替えています。

POST リクエスト

  .post("/", zValidator("form", schema), async (c) => {
    const data = c.req.valid("form")

    const post = await db
      .insert(posts)
      .values({
        id: createId(),
        text: data.text,
      })
      .returning()

    return c.json(post)
  })

POST リクエストは新しい投稿をデータベースに追加する際に利用されます。
Hono は Zod との互換性を持ち、zValidator ミドルウェアを通じてリクエストボディの検証を行うことができます。このケースでは、text フィールドが文字列型であるかをチェックし、最低 1 文字以上の文字列であることを確認しています。
検証に成功した場合、c.req.valid("form") で検証済みのデータを取得し、それを使って新規投稿をデータベースに挿入します。

クライアント側の実装

Hono のクライアント機能を設定します。

lib/hono.ts
import { hc } from "hono/client"

import { type AppType } from "@/app/api/[[...route]]/route"

export const client = hc<AppType>(process.env.NEXT_PUBLIC_APP_URL!)

hc 関数を利用することで、Hono の HTTP クライアントを簡単に生成できます。このクライアントは、API ルートの型定義を基に、API ルートの URL と型情報を取得する役割を持ちます。
AppType は API の型定義を示し、これを hc 関数に渡すことで、API リクエストとレスポンスの型安全性が確保されます。
実際client にマウスホバーすると、API ルートの URL と型情報が表示されることが確認できます。

e109287414eb8c

GET リクエストの呼び出し

Server Component の PostList コンポーネントで、投稿一覧を取得するための API ルートを呼び出します。

PostList の全コード
import { formatDistanceToNow } from 'date-fns';
import {
  Card,
  CardDescription,
  CardFooter,
  CardHeader,
} from '@/components/ui/card';
import { client } from '@/lib/hono';
import { InferResponseType } from 'hono';
import { fetcher } from '@/lib/utils';

const url = client.api.posts.$url();
type ResType = InferResponseType<typeof client.api.posts.$get>;

export const PostList = async () => {
  const posts = await fetcher<ResType>({
    url,
    next: {
      tags: ['posts'],
    },
  });

  return (
    <>
      {posts.map((post) => (
        <Card key={post.id}>
          <CardHeader>
            <CardDescription className='text-primary'>
              {post.text}
            </CardDescription>
          </CardHeader>
          <CardFooter className='border-t px-6 py-4 text-xs'>
            <p className='text-muted-foreground'>
              {formatDistanceToNow(new Date(post.createdAt), {
                addSuffix: true,
              })}
            </p>
          </CardFooter>
        </Card>
      ))}
    </>
  );
};

lib/hono.ts で定義した client を利用し、API ルートの URL を取得します。これにより、型情報が正しく適用され、オートコンプリート(補完)機能を活用して安全にコードを記述できます。
また、API ルートからのレスポンス型を取得する際には InferResponseType を使用します。

const url = client.api.posts.$url();
type ResType = InferResponseType<typeof client.api.posts.$get>;

export const PostList = async () => {
  const posts = await fetcher<ResType>({
    url,
    next: {
      tags: ['posts'],
    },
  });

  return <>...</>;
};

fetcher は、fetch API を少し拡張してもので、戻り値の型をジェネリック型で指定するようになっています。また、上記ではnextオプションを使って、キャッシュのタグを指定しています。

lib/utils.ts
type FetchArgs = Parameters<typeof fetch>

export async function fetcher<T>(url: FetchArgs[0], args: FetchArgs[1]) {
  const response = await fetch(url, args)

  return response.json() as Promise<T>
}

実際に Seed データを作成し、投稿データを作成し確認します。

Seed データの作成方法について

Seed データ(初期データ)を作成するために、scripts/seed.ts を作成します。

scripts/seed.ts
import "dotenv/config"

import { neon } from "@neondatabase/serverless"
import { createId } from "@paralleldrive/cuid2"
import { drizzle } from "drizzle-orm/neon-http"

import * as schema from "../db/schema"

const sql = neon(process.env.DATABASE_URL!)
const db = drizzle(sql, { schema })

const main = async () => {
  try {
    await db.delete(schema.posts)

    await db.insert(schema.posts).values([
      {
        id: createId(),
        text: "Hello, world!",
        createdAt: new Date(),
      },
      {
        id: createId(),
        text: "Goodbye, world!",
        createdAt: new Date(),
      },
    ])
  } catch (error) {
    console.error(error)
    throw new Error("Failed to seed the database")
  }
}

void main()

またスクリプトを package.json に追加します。

package.json
{
  "scripts": {
    "db:seed": "tsx ./scripts/seed.ts"
    ...
  }
}

実行して drizzle studio でデータが作成されていることを確認します。

e109287414eb8c

seed データが作成されたら、PostList コンポーネントを呼び出して投稿一覧が表示されることを確認します。

e109287414eb8c

POST リクエストの呼び出し

Next.js の機能である Server Actions を用いて POST リクエストを送り、新規投稿をどのように作成するかを見ていきます。

'use server';

import { client } from '@/lib/hono';
import { InferRequestType } from 'hono';
import { revalidateTag } from 'next/cache';

const $post = client.api.posts.$post;
type RequestType = InferRequestType<typeof $post>['form'];

export const createPost = async (data: RequestType) => {
  await $post({ data });

  revalidateTag('posts');
};

$postは、API ルートに定義された POST リクエストを呼び出すための関数です。
またInferRequestType を使うことで、Hono の強力な型推論を活用し、$post 関数のリクエストタイプを把握できます。これにより、TypeScript を用いて API リクエストのパラメータを安全に扱うことが可能になります。
加えて、Next.js のrevalidateTag を利用することで、投稿を作成した後にキャッシュを再検証し、最新の情報を表示できるようになります。この機能は、fetch API のオプションで設定した tags に基づいて動作します。

また、投稿作成フォームを作成して、handleSubmit 内で createPost 関数を呼び出します。

PostForm の全コード
'use client';

import { useRef } from 'react';
import { createPost } from '@/actions/post';
import { useFormState } from 'react-dom';

import { Button } from '@/components/ui/button';
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { FormErrorMessage } from '@/components/form-error-message';
import { FormSuccessMessage } from '@/components/form-success-message';
import { ActionStatus } from '@/app/validations/enums';

export const PostForm = () => {
  const ref = useRef<HTMLInputElement>(null);
  const [state, action, isPending] = useFormState(createPost, {
    status: ActionStatus.Idle,
    fields: {},
  });

  return (
    <Card>
      <CardHeader>
        <CardTitle>New Post</CardTitle>
        <CardDescription>Create a new post.</CardDescription>
      </CardHeader>
      <CardContent>
        <Input
          ref={ref}
          type='text'
          name='post'
          disabled={isPending}
          placeholder="What's on your mind?"
        />
      </CardContent>
      <CardFooter className='flex items-center justify-between'>
        <Button
          disabled={isPending}
          onClick={() => {
            if (ref.current) {
              action({
                text: ref.current.value,
              });
            }
          }}
        >
          Submit
        </Button>
        <div>
          {/* 成功メッセージ */}
          {state?.status === ActionStatus.Success && (
            <FormSuccessMessage message={state.message} />
          )}

          {/* エラーメッセージ */}
          {state?.status === ActionStatus.Error && (
            <div className='w-fit'>
              <ul className='space-y-2'>
                {state.issues.map((issue, index) => (
                  <li key={index}>
                    <FormErrorMessage message={issue} />
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      </CardFooter>
    </Card>
  );
};
components/post-form.tsx
"use client"

export const PostForm = () => {
  const ref = useRef<HTMLInputElement>(null)
  const [state, action, isPending] = useFormState(createPost, {
    status: ActionStatus.Idle,
    fields: {},
  })

  return (
    <Card>
      <CardHeader>
        <CardTitle>New Post</CardTitle>
        <CardDescription>Create a new post.</CardDescription>
      </CardHeader>
      <CardContent>
        <Input
          ref={ref}
          type="text"
          name="post"
          disabled={isPending}
          placeholder="What's on your mind?"
        />
      </CardContent>
      <CardFooter className="flex items-center justify-between">
        <Button
          disabled={isPending}
          onClick={() => {
            if (ref.current) {
              action({
                text: ref.current.value,
              })
            }
          }}
        >
          Submit
        </Button>
        <div>
          {/* 成功メッセージ */}
          {state?.status === ActionStatus.Success && (
            <FormSuccessMessage message={state.message} />
          )}

          {/* エラーメッセージ */}
          {state?.status === ActionStatus.Error && (
            <div className="w-fit">
              <ul className="space-y-2">
                {state.issues.map((issue, index) => (
                  <li key={index}>
                    <FormErrorMessage message={issue} />
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      </CardFooter>
    </Card>
  )
}

ここまでの実装を確認するため、入力欄にHello, Hono!と入力します。

e109287414eb8c

Submit ボタンをクリックすると、投稿が作成され、投稿一覧に追加されたことが確認できます。

e109287414eb8c

エラーハンドリング

先ほど説明した通り、POST リクエストで既に Zod を使ってリクエストボディの検証を行っています。試しに空のテキストのまま送信してみると、ターミナル上に 400 エラーが表示され投稿データが作成されないことが確認できます。

 POST /api/posts 400 in 298ms

このエラーをクライアント側で通知させるため、createPost関数を以下のように変更します。

actions/post.ts
export const createPost = async (
  _: FormState,
  form: RequestType
): Promise<FormState> => {
+ const parsedForm = schema.safeParse(form)
+ if (!parsedForm.success) {
+   return {
+     status: ActionStatus.Error,
+     issues: parsedForm.error.issues.map((issue) => issue.message),
+   }
+ }

- await $post({ form })
+ await $post({ form: parsedForm.data })

  revalidateTag("posts")

  return {
    status: ActionStatus.Success,
    message: "Success!",
  }
}

入力欄で空のテキストのまま送信すると、エラーメッセージが表示されることが確認できます。

e109287414eb8c

ダイナミックルートの実装

最後に投稿の詳細ページを作成します。
Next.js の Parallel Routes を使って、動的なルートを作成し、投稿の詳細情報を取得します。

API ルートの更新

:idとしてパスパラメータを受け取り、param メソッドを使ってその値を取得します。

app/api/[[...route]]/posts.ts
const schema = z.object({
  text: z.string(),
})

const app = new Hono()
  .get("/", async (c) => {
    const posts = await db.query.posts.findMany({
      orderBy: (posts, { desc }) => [desc(posts.createdAt)],
    })

    return c.json(posts)
  })
  .post("/", zValidator("form", schema), async (c) => {
    const data = c.req.valid("form")

    const post = await db
      .insert(posts)
      .values({
        id: createId(),
        text: data.text,
      })
      .returning()

    return c.json(post)
  })
+ .get("/:id", async (c) => {
+   const post = await db.query.posts.findFirst({
+     where: eq(posts.id, c.req.param("id")),
+   })

+   return c.json(post)
+ })

export default app

クライアント側の実装

実装前に最終的なフォルダ構成を確認します。

e109287414eb8c

default.tsx を必要としたり、layout.tsx の props にスロットを追加するなど、Parallel Routes を使うための前準備が必要となりますが、ここでは割愛します。公式ドキュメントを参照してください。

https://nextjs.org/docs/app/building-your-application/routing/parallel-routes

一覧取得処理と同様に、詳細ページの API ルートを呼び出すため url と型情報を取得し、fetcher 関数を使ってデータを取得します。

app/@dialog/[id]/page.tsx
import React, { Suspense } from "react"
import { formatDistanceToNow } from "date-fns"
import { type InferResponseType } from "hono"
import { Loader } from "lucide-react"

import { client } from "@/lib/hono"
import { fetcher } from "@/lib/utils"

import DialogWrapper from "./_components/dialog-wrapper"

type Props = {
  params: {
    id: string
  }
}

const Page = ({ params }: Props) => {
  const postId = params.id

  return (
    <DialogWrapper>
      <Suspense key={postId} fallback={<Loader />}>
        <Details id={postId} />
      </Suspense>
    </DialogWrapper>
  )
}

export default Page


const Details = async ({ id }: { id: string }) => {
  // API ルートの URL を取得
  const url = client.api.posts[":id"].$url({ param: { id } })
  // API ルートのレスポンス型を取得
  type ResType = InferResponseType<typeof client.api.posts.$get>[number]

  const post = await fetcher<ResType>({
    url,
    next: {
      tags: [`posts/${id}`],
    },
  })

  return (
    <div className="flex flex-col gap-8">
      <h1 className="text-center text-2xl font-bold">{post.text}</h1>
      <p className="text-right text-sm text-gray-500">
        {formatDistanceToNow(new Date(post.createdAt), {
          addSuffix: true,
        })}
      </p>
    </div>
  )
}

一覧ページのカードに Link タグを仕込んで遷移させると、詳細ページ(ダイアログ)が表示されることを確認します。

e109287414eb8c

この記事では割愛しますが、投稿データの更新や削除などの機能を同じ要領で実装することができます。

まとめ 📌

この記事を通して、Next.js の Route Handlers を Hono に置き換えるアプローチを紹介しました。Hono を取り入れることで、API ルートの URL や戻り値の型を自動的に取得できるようになり、より信頼性の高いコードを簡単に書くことができるようになります。
またサードパーティとして Zod との互換性を持つ Hono は、リクエストボディの検証を行うことができ、型安全性を高めることができます。他にも Auth.js や GraphQL など、様々なライブラリとの組み合わせが可能なようなので、今後試してみたいと思います。

以上です!

参考 📚

https://hono.dev/guides/rpc
https://hono.dev/getting-started/vercel
https://hono.dev/middleware/third-party#third-party-middleware

chot Inc. tech blog

Discussion

rión_devopsrión_devops

有益な記事をありがとうございます。
細かいところですが、以下の

type FetchArgs = Parameters<typeof fetch>

export async function fetcher<T>(url: FetchArgs[0], args: FetchArgs[1]) {
  const response = await fetch(url, args)

  return response.json() as Promise<T>
}

return response.json() as Promise<T>の部分は

Conversion of type '() => Promise<any>' to type 'Promise<T>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.ts(2352)

のエラーになってしまうため、return response.json() as unknown as Promise<T>
とする必要がありそうです。