🔥

[入門] Next.js+Hono+Bunのモノレポ構成で型安全なWebアプリ開発をする

2025/02/23に公開
1
85

こんにちは、やせです。
普段はゆるく個人開発をしている学生です。

はじめに

本記事では、Next.js+Hono+Bunを用いて、型安全にWebアプリ開発を行う方法を入門者向けに説明していきます。

また、本記事では以下の内容も扱います。ZodやDrizzleを用いて、データベースからフロントエンドまでを型安全に開発することができます。

  • Bun Workspacesを使用したモノレポ構成
  • HonoのRPC機能
  • Zodによる型安全なバリデーション
  • Supabase DB + Drizzle ORMによるDB操作・マイグレーション
  • TanStack Queryを使用したデータフェッチ
  • Next.jsで作成したSPAをCloudflare Pagesへデプロイ
  • Honoで作成したAPIサーバーをCloudflare Workersへデプロイ

各章の最後には、実際に手を動かして学びたい人向けにハンズオン形式で実行できるようにしてあるので、興味がある人はぜひやってみてください。最終的には簡単なTODOアプリが作れるようになります。

Bun Workspacesのモノレポ構成

モノレポ構成とは

複数のプロジェクトやパッケージに関するコードを、一つのレポジトリで管理する方式を「モノレポ」と呼びます。反対に、各プロジェクトやパッケージごとに、レポジトリを分ける方式を「ポリレポ」と呼びます。

モノレポでは以下のように、一つのレポジトリ内で複数のプロジェクトを扱います。

<root>
├── README.md
├── package.json
├── tsconfig.json
└── projects
    ├── project-a
    │   ├── index.ts
    │   ├── package.json
    │   └── tsconfig.json
    ├── project-b
    │   ├── index.ts
    │   ├── package.json
    │   └── tsconfig.json
    └── project-c
        ├── index.ts
        ├── package.json
        └── tsconfig.json

モノレポでプロジェクトを管理することで、可視性の向上コードの共有が簡単になる等のメリットがある一方で、コード変更による影響範囲の増加依存関係の複雑化等のデメリットがあります。
以下にモノレポのメリットとデメリットをまとめました。

プロジェクトやチームの特性を考えてどちらの管理方式を採用するのかが大事なポイントだと思います。

参考資料👇
https://zenn.dev/burizae/articles/c811cae767965a
https://circleci.com/ja/blog/monorepo-dev-practices/

Bunとは

Bunは高速なJavaScriptのランタイムで、Node.jsやDenoの代替として注目されています。特徴としては以下のようなものがあります。

  • 高速な実行速度
  • バンドラー、テスト、ランナー、パッケージマネージャーを完備
  • Node.jsとの高い互換性
  • TypeScript & JSXをネイティブサポート
  • etc...

公式ページ👇
https://bun.sh/

個人的には、NodeからBunに移行したことによって、CI/CDの時間が劇的に短縮され、開発体験がすごく向上したと感じています。

Bun Workspacesとは

次に、Bun Workspacesについて説明します。
Bun Workspacesとは、複数のプロジェクトのパッケージを、ルートディレクトリで管理できる機能です。

一般的に、モノレポ構成では依存関係の管理が複雑になりますが、Workspaces機能を使うことで、依存関係管理が簡単になります。Workspacesではルートでnode_modulesを管理するので、プロジェクトごとにnode_modulesを持たなくて済みます。また、共通の依存バージョンを一元管理し、バージョンの不一致を防止することもできます。

参考資料👇
https://bun.sh/docs/install/workspaces

また、同様のツールとして以下のようなものがあります。

🚀ハンズオン

実際にBunでWorkspacesを構築する方法を説明します。

はじめに、以下のページからBunをインストールします。
https://bun.sh/docs/installation

以下のコマンドでバージョンが正しく表示されれば大丈夫です。本記事ではバージョン1.2.2を使用します。

$ bun -v
1.2.2

次に、作業するディレクトリを作成します。プロジェクト名はsample-appを使用しますが、適宜変更してください。

$ mkdir sample-app
$ cd sample-app

以下のpackage.jsonファイルをルートフォルダ直下に作成します。
workspacesapps/*を指定することでapps直下にあるディレクトリがワークスペースとして認識されます。

package.json
{
    "name": "sample-app",
    "private": true,
    "workspaces": [
        "apps/*"
    ]
}

.gitignoreファイルを作成しておきます。

.gitignore
node_modules

必要なディレクトリを作成しておきます。中身は後で実装していきます。

$ mkdir apps

参考資料👇
https://bun.sh/guides/install/workspaces

HonoのRPC機能

Honoとは

HonoのRPC機能について説明する前に、軽くHonoについて説明します。
Honoは軽量で高速なJS/TSのWebフレームワークであり、Cloudflare Workersのようなエッジ環境で動くのが大きなポイントです。

https://hono.dev

エッジ環境とは、ユーザーに近い場所に分散配置されたサーバー(エッジサーバー)を中心としたコンピューティング環境のことをいいます。従来は、一か所のサーバーですべてのリクエストを処理する中央集権的なやり方が一般的でした。しかし、この方法だとサーバーに負荷が集中してしまったり、サーバーから離れているユーザーのレイテンシが大きくなってしまうといった問題がありました。

そこで各地に配置されたエッジサーバー上でリクエストを処理するといった考えが普及していきました。ユーザーから一番近いエッジサーバーにリクエストを送るため、低遅延・低負荷を実現できるようになりました。

Honoは、このエッジサーバー上で動作することが大きな特徴となっています。
また、Honoは国産のフレームワークとなっており、Yusuke Wada さんが作っています🔥
こちらの記事でご本人様が詳しく説明しています👇
https://zenn.dev/yusukebe/articles/0c7fed0949e6f7

HonoのRPC!

次にHonoのRPC機能について説明します。RPC機能を使用することで、フロントエンドとバックエンド間の通信を型安全に行うことができます。

こちらもご本人様が詳しく説明しています👇
https://zenn.dev/yusukebe/articles/a00721f8b3b92e

一般的にフロントエンドからバックエンドに通信を行うとき、以下のコードのようにfetch関数がよく用いられます。しかし、fetch関数では画像のように、取得したdataの型がanyとなってしまいます。

HonoのRPC機能を使用することで、このdataに型を付けることができるようになります。
例として、/にGETリクエストを送ると{"message" : "Hello World!"}が返ってくるAPIサーバーを考えます。

バックエンドのサンプルコード
import { Hono } from 'hono'

const app = new Hono()

const route = app.get('/', (c) => {
    return c.json({ message: 'Hello World!' }, 201)
  }
)

export type AppType = typeof route

このとき、RPCを使ってフロントエンドからリクエストを送るコードは以下のようになります。

フロントエンドのサンプルコード
import { hc } from "hono/client";
import type { AppType } from "backend/src/index";

const client = hc<AppType>("http://localhost:8080");
const res = await client.index.$get();
const data = await res.json(); // dataに型が付く
console.log(data);

すると、以下のように自動でdataに型を付けてくれます。

このようにして、バックエンドで作成したAPIのコードから、フロントエンドで使える型を自動で生成してくれるのがRPCの強みです。型が付くおかげで、エディタの補完が使えるようになったり、API側のスキーマ変更が直接クライアントに伝えられるなど、様々な恩恵を受けることができ、非常に開発体験が良いと感じています。

🚀ハンズオン

では、実際にRPCを使ってフロントエンドとバックエンドで通信するコードを書いていきます。

はじめに、Next.jsアプリを作成します。
以下のコマンドで、テンプレートを作成します。

$ cd apps
$ bun create next-app@latest frontend
✔ Would you like to use TypeScript? … No / Yes # Yes
✔ Would you like to use ESLint? … No / Yes # Yes
✔ Would you like to use Tailwind CSS? … No / Yes # Yes
✔ Would you like your code inside a `src/` directory? … No / Yes # Yes
✔ Would you like to use App Router? (recommended) … No / Yes # Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes # No
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes # No

同様に、以下のコマンドでHonoアプリを作成します。

$ bun create hono@latest backend
create-hono version 0.15.3
✔ Using target directory … backend
? Which template do you want to use?
  aws-lambda
  bun
  cloudflare-pages
❯ cloudflare-workers # 選択する
  deno
  fastly
  lambda-edge
? Do you want to install project dependencies? yes # Yesを選択
? Which package manager do you want to use? (Use arrow keys)
  npm
❯ bun # 選択する
  pnpm

すると、以下のようにappsディレクトリにfrontend backendというフォルダが作成されます。
さらに、ルートディレクトリにnode_modulesbun.lockが作成されます。このようにWorkspacesを使用することで、ルートディレクトリでnode_modulesが管理されます。

sample-app
├── bun.lock
├── node_modules
│   ├── backend # backendフォルダへのシンボリックリンクが作成される
│   └── frontend # frontendフォルダへのシンボリックリンクが作成される
├── package.json
└── apps
    ├── backend
    └── frontend

バックエンドのファイルを編集します。ここではroutesの型をexportすることでフロント側で使えるようにしています。また、フロントからAPIを叩けるようにCORSを設定しています。

apps/backend/src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'

const app = new Hono()

app.use('*', cors({
  origin: '*'
}))
const route = app.get('/hello', (c) => {
  return c.json({ message: 'Hello Hono!' })
})
export type AppType = typeof route

export default app

次にフロントエンドで必要な依存関係をインストールします。ここでは、バックエンドでエクスポートしている型を使用するため、backendを依存関係に入れます。

$ cd apps/frontend
$ bun add hono backend

次に、フロントエンドのファイルも編集します。apps/frontend/src/utils/client.tsファイルを作成して、Honoクライアントを作成する処理を書きます。ここでAppTypeをバックエンドからインポートするのですが、Workspacesを使用しているため、相対パスではなく、backend/srcのようにパスを指定することができます。

apps/frontend/src/utils/client.ts
import { AppType } from "backend/src"; // Workspacesを使用しているため、"../../backend..."のように相対パスで書かなくて良い
import { hc } from 'hono/client'

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

.envファイルを作成して、NEXT_PUBLIC_API_URLを設定します。

apps/frontend/.env
NEXT_PUBLIC_API_URL=http://localhost:8080

page.tsxファイルを編集します。ボタンを押すとRPCでAPIサーバーへリクエストを送り、アラートで表示するコードです。

apps/frontend/src/app/page.tsx
'use client'

import { client } from "@/utils/client";

export default function Home() {
  const handleClick = async () => {
    const res = await client.hello.$get()
    const data = await res.json()
    alert(data.message)
  }
  return (
    <div>
      <button onClick={handleClick}>Click me</button>
    </div>
  );
}

globals.cssファイルを編集し、余分なCSSを削除しておきます。

apps/frontend/src/app/globals.css
@import "tailwindcss";

ここで、apps/frontend/src/app/page.tsxファイルのdata.messageを見てみると、RPC機能により、バックエンドで定義した型が付いていることが確認できると思います。このようにしてRPC機能を使用すると、バックエンドで作成されたAPIのコードから型が自動で作成され、フロントエンドでその型を使用することができます。

サーバーを起動して動作確認したいのですが、その前に、バックエンドのpackage.jsonを編集して起動するポートを変えておきます。また、wrangler.jsoncも編集してNode.js互換モードを有効にするフラグを追加しておきます。これをやっておかないと後々動作しません。

apps/backend/package.json
{
  "name": "backend",
  "scripts": {
-    "dev": "wrangler dev",
+    "dev": "wrangler dev --port 8080",
    "deploy": "wrangler deploy --minify"
  },
  "dependencies": {
    "hono": "^4.7.2"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20250109.0",
    "wrangler": "^3.101.0"
  }
}
wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "backend",
  "main": "src/index.ts",
-  "compatibility_date": "2025-02-21"
+  "compatibility_date": "2025-02-21",
+  "compatibility_flags": [
+    "nodejs_compat"
+  ]
  ...
}

サーバーを起動します。Bun Workspacesを使用している場合、以下のコマンドでフロントとバック両方のサーバーを立ち上げることができます。(各ディレクトリに移動してbun run devを実行しても構いません。)

# ルートディレクトリで実行(sample-appディレクトリ)
$ bun run --filter '*' dev # まとめて起動
or
$ cd apps/backend && bun run dev
$ cd apps/frontend && bun run dev

サーバーを起動して、http://localhost:3000にアクセスし、ボタンをクリックするとAPIサーバーにリクエストが送られ、以下のようにHello Hono!と表示されます。

本章では、HonoのRPC機能を用いて、フロントエンドとバックエンドの通信を型安全に行う方法を説明しました。次章では、Zodを使用したバリデーションを説明します。

Zodによる型安全なバリデーション

Zodとは

Zodとは、静的型推論によるTypeScriptファーストのスキーマ宣言およびバリデーションライブラリです。
https://zod.dev/

公式ドキュメントによると、以下のような特徴があります。

  • 開発者に使いやすい設計
  • スキーマから自動で型推論
  • 依存関係ゼロ
  • Node.jsとすべての最新ブラウザで動作
  • 軽量(8kb minified + zipped)
  • イミュータブル
  • 関数型アプローチ

このZodを使用することで、スキーマを定義すると自動で型推論を行ってくれて、型安全にバリデーションを行うことができ、ランタイムエラーのリスクを減らすことができます。

例として、ユーザー名をバリデーションを行うコードを考えます。
以下のように、単なるバリデーションを行うだけでは、data.name型はanyのままになります。

❌ Zodを使わない例
function processUser(data: any) {
  if (typeof data.name !== "string") {
    throw new Error("Invalid name");
  }
  return data.name.toUpperCase(); // data.name の型は "any" のまま
}

ここで、Zodを使用してスキーマを宣言し、解析を行うことでuserにはスキーマで宣言したデータ構造が入るため、user.namestring型となり、これ以降、userを安全に使用することでき、ランタイムエラーを回避しつつ開発を行うことができます。

✅ Zodを使った例
import { z } from "zod"; // Zodライブラリを使用

// スキーマを宣言
const UserSchema = z.object({
  name: z.string(),
});

function processUser(data: unknown) {
  const user = UserSchema.parse(data); // 型が保証される
  return user.name.toUpperCase(); // 安全にアクセス可能
}

補足:Zodは関数型の思想が取り入れられており、こちらの記事で詳しく解説されています👇
https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate

Hono + Zod middlewareでバリデーション

Honoでは豊富なミドルウェアが用意されており、Zodにもミドルウェアが用意されています。
ミドルウェアを通すことで、Zodで定義したスキーマをもとに、リクエストデータを簡単にバリデーションして、型安全にリクエストデータを扱うことができます。
https://hono.dev/docs/guides/validation#zod-validator-middleware

サンプルコードを以下に示します。
POSTメソッドでnameをリクエストデータに含めると"Hello 名前"のように返ってきます。

Zod middlewareのサンプルコード
const route = app
  .post('/hello', zValidator('json', z.object({
    name: z.string(),
  })), (c) => {
    const { name } = c.req.valid('json')
    return c.json({ message: `Hello ${name}!` })
  })

すると、以下のようにvalid関数を通すことで、Zodで定義したスキーマのデータ構造となるのが保証されます。このように、便利なミドルウェアが他にもたくさん用意されているのがHonoの良いところです。

🚀ハンズオン

それでは実際にZod middlewareを使用して、型安全にバリデーションをします。
はじめに、zod @hono/zod-validatorをバックエンドにインストールします。

$ cd apps/backend
$ bun add zod @hono/zod-validator

次に、apps/backend/src/index.tsのファイルを編集していきます。
titledescriptionを受け取るtodoというエンドポイントを作成します。バリデーションの内容は以下のようにします。

apps/backend/src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono()

app.use('*', cors({
  origin: '*'
}))

const todoSchema = z.object({
  title: z.string().min(2),
  description: z.string().nullable(),
})

const route = app
  .get('/hello', (c) => {
    return c.json({ message: 'Hello Hono!' })
  })
  .post('/todo', zValidator('json', todoSchema, (result, c) => {
    if (!result.success) {
      return c.text(result.error.issues[0].message, 400)
    }
  }),
    (c) => {
      const { title, description } = c.req.valid('json')
      return c.json({ title, description })
  })
export type AppType = typeof route

export default app

フロントエンドのファイルも編集していきます。React19から導入されたuseActionStateを使用して、簡単な入力フォームと送信ボタンを作成しました。ボタンを押すとAPIにリクエストを送り、エラーがあればエラーメッセージを表示します。

apps/frontend/src/app/page.tsx
'use client'

import { client } from "@/utils/client"
import { useActionState } from "react"

export default function Home() {
  const formAction = async (prevError: string | null, formData: FormData) => {
    const title = formData.get('title') as string
    const description = formData.get('description') as string
    const res = await client.todo.$post({
      json: { title, description },
    })
    if (!res.ok) {
      const error = await res.text()
      return error
    }
    return null
  }
  const [error, submitAction, isPending] = useActionState(formAction, null)

  return (
    <div className="mt-10">
      <h1 className="text-3xl font-bold text-center">Todo</h1>
      <form action={submitAction} className="flex flex-col gap-2 max-w-[600px] mx-auto mt-10">
        <label htmlFor="title" className="text-sm font-medium">Title</label>
        <input type="text" name="title" className="border-2 border-gray-300 rounded-md p-2" />
        <label htmlFor="description" className="text-sm font-medium">Description</label>
        <input type="text" name="description" className="border-2 border-gray-300 rounded-md p-2" />
        <button disabled={isPending} type="submit" className="bg-blue-500 text-white p-2 rounded-md">Submit</button>
        {error && <p className="text-red-500">{error}</p>}
      </form>
    </div>
  );
}

このときapps/frontend/src/app/page.tsxでPOSTリクエストを送る際に、jsonに自動で型が付いているのが確認できます。このようにして、バリデーション用のスキーマを定義しZod middlewareを使用するだけで、フロントエンド側のリクエストデータに対しても型が付くのがとても便利ですね。

Titleを空文字で送信すると、バリデーションが通らず、Zodが生成するエラーメッセージが表示されるようになっています。

このようにして、Zod middlewareを用いることで、簡単かつ型安全にバリデーションを行うことができます。
本章では、Zodを使用して型安全にバリデーションを行う方法を説明しました。次章ではSupabase DBとDrizzle ORMを用いてDB操作を行います。

Supabase DB + Drizzle ORM

Supabaseとは

Supabaseとは、Firebaseの代替を謳っているオープンソースのバックエンドプラットフォームです。データベースや認証、ストレージ、リアルタイム機能を提供しており、Supabaseを使用することでバックエンドの基本的な機能を簡単に実装することができます

本記事では無料で使用できるサーバーレスDBを使用したかったため、Supabaseの提供するデータベースを使用することにしました。

〇 Supabaseの無料プランの特徴

  • 2つのプロジェクトまで作成可能
  • 1週間使用されないと自動で一時停止される
  • データベースは500MBまで使用可能

無料プランでも十分な使えてかつ、スケーリングしたときもアップグレードできたり、OSSなのでセルフホスティングできたりと、とても良いサービスだと思います。

参考資料👇
https://supabase.com/
https://supabase.com/pricing

Drizzle ORMとは

Drizzle ORMとは、TypeScript/JavaScript向けの軽量で型安全なORMライブラリです。Drizzleを使用することで、データベーススキーマをTypeScriptで記述することができたり、型安全にSQLを実行することができます。

特徴としては、以下のようなものがあります。

  • 型安全にSQLを実行できる
  • SQL構文とほぼ同じ書き方(「SQLを知っていれば、Drizzleも分かる」と謳っている)
  • 軽量(7.4kb minified+gzipped)
  • 依存関係がゼロ
  • 豊富なサーバーフル・サーバーレスランタイムをサポート
  • エッジサーバーやブラウザなどのあらゆるランタイムで動作

参考資料👇
https://orm.drizzle.team/
https://orm.drizzle.team/docs/overview

個人的に、このふざけた感じが結構好きです笑

他にも、TypeScriptには以下のようなORMやクエリビルダーがあります👇

Drizzleを使用したマイグレーション

マイグレーションとは、データベースのスキーマやデータを変更・移行するプロセスです。
DBのスキーマに新しいカラムを追加・変更したいときに、どのようなカラムが追加されるのか削除されるのか、といった変更内容を記述したファイルを作成し、そのファイルの内容を実行してDBのスキーマを変更するという方法です。

Drizzleではマイグレーション機能が提供されており、drizzle-kitのツールを使用することで、マイグレーションを簡単に実行することができます。

参考資料👇
https://orm.drizzle.team/docs/migrations

🚀ハンズオン

それでは、Supabase DBとDrizzle ORMを使用して、データベース操作を行います。

以下のページから、Supabaseのプロジェクトを作成します。
https://supabase.com/dashboard/projects

New Project→your's Orgを押して、プロジェクト名とパスワードを入力します。リージョンはTokyoにしておきます。パスワードは後で使用するので保存しといてください。入力したらCreate new projectを押してプロジェクトを作成します。

Connectを押します。

Direct connectionのURIをコピーしたら、バックエンドプロジェクトに.dev.varsファイルを作成して、DATABASE_URLにコピーしたURIを記述します。[PASSWORD]は先程保存したパスワードに変更しておきます。

apps/backend/.dev.vars
DATABASE_URL=コピーしたURI

次に、Drizzleをインストールしておきます。

$ cd apps/backend
$ bun add drizzle-orm postgres dotenv
$ bun add -D drizzle-kit tsx @types/node

Drizzleを使用してスキーマを定義します。

apps/backend/src/db/schema.ts
import { integer, pgTable, varchar, timestamp } from "drizzle-orm/pg-core";

export const todosTable = pgTable("todos", {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  title: varchar({ length: 255 }).notNull(),
  description: varchar({ length: 255 }),
  createdAt: timestamp().notNull().defaultNow(),
});

Drizzleの設定ファイルdrizzle.config.tsを作成します。

apps/backend/drizzle.config.ts
import { config } from 'dotenv';
import { defineConfig } from 'drizzle-kit';

config({ path: '.dev.vars' });

export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

以下のコマンドで、マイグレーションファイルを作成します。

$ bunx drizzle-kit generate

このコマンドを実行するとapps/backend/drizzleディレクトリが作成され、以下のように0000_lyrical_mysterio.sqlのようなファイルとmetaディレクトリが作成されます。これらのファイルはマイグレーションが実行される時に使用されます。

drizzle
├── 0000_lyrical_mysterio.sql # マイグレーションのSQLファイル
└── meta # マイグレーションのメタデータ
    ├── 0000_snapshot.json
    └── _journal.json

以下のようなマイグレーションファイルが作成されますが、作成されるファイル名が同じにならないことに注意してください。

0000_lyrical_mysterio.sql
CREATE TABLE "todos" (
	"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "todos_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
	"title" varchar(255) NOT NULL,
	"description" varchar(255),
	"createdAt" timestamp DEFAULT now() NOT NULL
);

以下のコマンドでマイグレーションを実行します。このコマンドによって、作成したマイグレーションファイルをもとに、DBにスキーマが反映されます。

$ bunx drizzle-kit migrate

マイグレーションを実行したら、スキーマが正しく反映されているのかを確認しています。
以下のコマンドでDrizzle Studioを起動します。Studioとは、データベースの内容を確認したり、データを追加したりすることができるツールです。

$ bunx drizzle-kit studio

起動してhttps://local.drizzle.studioにアクセスすると、以下のようにデータベースの内容を確認できる画面が表示されます。

Studioを見てみると、作成したスキーマが正しくデータベースに反映されていることが確認できます。

次に、バックエンドのコードを編集して、Drizzleを使用してデータベースにデータを追加する処理と、取得する処理を実装します。

apps/backend/src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { todosTable } from './db/schema'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'

export type Env = {
  DATABASE_URL: string;
};

const app = new Hono<{ Bindings: Env }>();

app.use('*', cors({
  origin: '*'
}))

const todoSchema = z.object({
  title: z.string().min(2),
  description: z.string().nullable(),
})

const route = app
  .get('/hello', (c) => {
    return c.json({ message: 'Hello Hono!' })
  })
  .post('/todo', zValidator('json', todoSchema, (result, c) => {
    if (!result.success) {
      return c.text(result.error.issues[0].message, 400)
    }
  }),
    async (c) => {
      const { title, description } = c.req.valid('json')
      const client = postgres(c.env.DATABASE_URL, { prepare: false })
      const db = drizzle({ client })
      const todo = await db.insert(todosTable).values({ title, description }).returning()
      return c.json({ todo: todo[0] })
    })
  .get('/todos', async (c) => {
    const client = postgres(c.env.DATABASE_URL, { prepare: false })
    const db = drizzle({ client })
    const todos = await db.select().from(todosTable)
    if (!todos) {
      return c.text('Failed to fetch todos', 500)
    }
    return c.json({ todos })
  })
export type AppType = typeof route

export default app

Drizzleを使用することで、以下のように、クエリの結果にスキーマで定義した型を付けることができます。

また、以下のように、スキーマで定義していないプロパティを指定するとエディタ上でエラーが出るため、実行前にエラーに気づくことができます。このようにしてDrizzleを使用することで、型安全にデータベース操作を行うことができます。

次に、フロントエンドのコードを編集して、バックエンドのAPIサーバーと通信する処理を実装します。フロントエンドでは、データフェッチライブラリにTanStack Queryを使用します。
https://tanstack.com/query/latest

TanStack Queryは、Reactのコンポーネント内でデータをフェッチ、キャッシュ、更新などができるライブラリです。
以下の本で詳しく説明されているので、詳しく知りたい方は読んでみると良いかもしれません👇
https://zenn.dev/taisei_13046/books/133e9995b6aadf

TanStack Queryを以下のコマンドでインストールします。

$ cd apps/frontend
$ bun add @tanstack/react-query

TanStack Queryを使用するためには、ルートレイアウトでプロバイダーを設定する必要があります。そのためプロバイダーを作成し、apps/frontend/src/app/layout.tsxを編集します。

apps/frontend/src/app/Provider.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function Provider(
  { children } : { children: React.ReactNode }
) {
  const queryClient = new QueryClient()
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
apps/frontend/src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import Provider from "./Provider";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <Provider>
          {children}
        </Provider>
      </body>
    </html>
  );
}

これで、TanStack Queryを使用する準備ができました。それでは、フロントエンドでフォームの入力するコンポーネントと、データを取得するコンポーネントを作成していきます。

入力用のコンポーネントを作成します。ここでは、React19から導入されたuseActionStateを使用しています。

apps/frontend/src/components/TodoInput.tsx
import { client } from "@/utils/client"
import { useQueryClient } from "@tanstack/react-query"
import { useActionState } from "react"

const TodoInput = () => {
    const queryClient = useQueryClient() // キャッシュを更新するためのクエリクライアント
    const formAction = async (prevError: string | null, formData: FormData) => {
        const title = formData.get('title') as string
        const description = formData.get('description') as string
        const res = await client.todo.$post({
            json: { title, description },
        })
        if (!res.ok) {
            const error = await res.text()
            return error
        }
        queryClient.invalidateQueries({ queryKey: ['todos'] }) // データを更新したら、キャッシュを更新する
        return null
    }
    const [error, submitAction, isPending] = useActionState(formAction, null)

    return (
        <form action={submitAction} className="flex flex-col gap-2 max-w-[600px] mx-auto mt-10">
            <label htmlFor="title" className="text-sm font-medium">Title</label>
            <input type="text" name="title" className="border-2 border-gray-300 rounded-md p-2" />
            <label htmlFor="description" className="text-sm font-medium">Description</label>
            <input type="text" name="description" className="border-2 border-gray-300 rounded-md p-2" />
            <button disabled={isPending} type="submit" className="bg-blue-500 text-white p-2 rounded-md">Submit</button>
            {error && <p className="text-red-500">{error}</p>}
        </form>
    )
}

export default TodoInput

データを取得するコンポーネントを作成します。データの取得にTanStack QueryのuseQueryを使用しています。これを用いることで、データのキャッシュや更新を管理してくれます。

apps/frontend/src/components/Todos.tsx
'use client'

import { client } from "@/utils/client"
import { useQuery } from "@tanstack/react-query"

const getTodos = async () => {
    const res = await client.todos.$get()
    const { todos } = await res.json()
    return todos
}

const Todos = () => {
    const query = useQuery({ queryKey: ['todos'], queryFn: getTodos }) // データを取得するためのクエリ
    return (
        <div className="pb-10">
            {query.data?.map((todo) => (
                <div key={todo.id} className="max-w-[600px] mx-auto mt-4 p-4 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
                    <h3 className="text-lg font-semibold text-gray-800">{todo.title}</h3>
                    {todo.description && (
                        <p className="mt-2 text-gray-600">{todo.description}</p>
                    )}
                </div>
            ))}
        </div>
    )
}

export default Todos
apps/frontend/src/app/pages.tsx
'use client'

import Todos from "@/components/Todos";
import TodoInput from "@/components/TodoInput";

export default function Home() {
  return (
    <div className="mt-10">
      <h1 className="text-3xl font-bold text-center">Todo</h1>
      <TodoInput />
      <Todos />
    </div>
  );
}

ここまでで、http://localhost:3000にアクセスし、Todoを入力して送信すると、APIサーバーにリクエストが送信され、データベースに保存されるようになります。データベースに保存されているため、リロードを押しても、データが消えていないことが確認できると思います。

本章では、Supabase DBとDrizzle ORMを使用して、データベース操作を行う方法を説明しました。次章では、Cloudflareへのデプロイを行います。

Next.jsアプリをCloudflare Pagesへデプロイ

Cloudflare Pagesとは

Cloudflareは、Webアプリケーションのセキュリティやパフォーマンスを向上させるサービスです。Cloudflareを活用することで、簡単に高パフォーマンスでセキュアなWebサービスを構築することができます。

本記事では、Cloudflareの提供しているCloudflare Pagesを使用して、Webアプリケーションをデプロイします。

Cloudflare Pagesは、以下のような特徴があります。

個人的には、無料枠が充実しているのがとても推しポイントです👇

参考資料
https://www.cloudflare.com/ja-jp/learning/what-is-cloudflare/
https://www.cloudflare.com/ja-jp/developer-platform/products/pages/

他にもデプロイ先にVercelというNext.jsを作成している企業のサービスがあります。
こちらもNext.jsアプリを無料でデプロイすることができ、様々なフレームワークにも応しています。Cloudflare Pages同様、GitHubレポジトリと連携し、CI/CDを行うことができたり、静的コンテンツをCDNのエッジサーバーから配信してくれるといった特徴がります。ただし、注意点として無料プランだと商用利用ができないので注意が必要です。

https://vercel.com/

🚀ハンズオン

それでは、作成したNext.jsアプリをCloudflare Pagesにデプロイします。本記事ではNext.jsをStatic exportsして静的サイトとしてデプロイします。Static exportsすることで、Next.jsアプリを静的ファイルとしてビルドすることができます。

https://nextjs.org/docs/pages/building-your-application/deploying/static-exports

設定ファイルを編集してNext.jsアプリをStatic exportsにします。これにより、静的ファイルとしてビルドされます。

apps/frontend/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
+  output: 'export',
};

export default nextConfig;

次にsample-appGitHubレポジトリを作成してプッシュしておきます。(やり方省略)

Cloudflare Pagesのサイトにアクセスします。
https://dash.cloudflare.com/sign-up/workers-and-pages

作成を押す→タブのPagesを選択→Gitに接続 を選択します。

GitHubアカウントを連携させて、先ほど作成したリポジトリを選択し、セットアップの開始を押します。すると以下のような画面になるはずです。

各入力項目を以下のように入力します。
フレームワークプリセット:Next.js(Static HTML Exports)
ビルドコマンド:bun install && bun run build
ビルド出力ディレクトリ:out
ルートディレクトリ(アドバンスト)
・パス:apps/frontend
環境変数(アドバンスト)
BUN_VERSION1.2.2
NODE_VERSION20.9.0

「保存してデプロイする」を押します。するとビルドが始まり、ビルドが完了するとデプロイされます。「○○でプレビューできます」とURLが表示されるので、アクセスして以下のように表示されれば成功です。

APIサーバーをまだデプロイしていないため、今のままでは動きません。次章でAPIサーバーをデプロイし、本番環境でも動くようにします。

APIサーバーをCloudflare Workersにデプロイする

Cloudflare Workersとは

Cloudflare Workersは、Cloudflareのエッジ環境で動作するサーバーレスプラットフォームです。Cloudflareのグローバルネットワーク上のエッジ上で実行されるため、低遅延かつ高速なサービスを実現できます。こちらもCloudflare Pagesと同じく、無料枠が充実しているのも特徴です。

Cloudflare Workersについては、こちらの記事で詳しく解説されています👇
https://zenn.dev/moutend/articles/97c98a277f4bae

🚀ハンズオン

Honoで作成したAPIサーバーをCloudflareにデプロイします。以下のコマンドで簡単にデプロイすることができます。

# backendディレクトリで実行
$ bun run deploy

コマンドを実行するとログイン画面や確認画面が表示されるので、案内に従って進めてください。以下のように表示されたら成功です。このとき、表示されるURLを後で使うので保存しておいてください。

Successfully logged in.
Total Upload: 223.84 KiB / gzip: 67.94 KiB
Worker Startup Time: 19 ms
No bindings found.
Uploaded backend (3.13 sec)
Deployed backend triggers (0.34 sec)
  https://..........workers.dev # 後で使用するので保存しておいてください
Current Version ID: ace1c54e-04a0-4065-a32b-30337a6b9ba1

この時点では、環境変数を設定していないため正しく動作しません。Wrangler CLIを使用して環境変数を設定します。以下のコマンドを実行すると、値の入力を求められるので、.dev.varsファイルのDATABASE_URLの値を入力します。(上手くコピペできないひとはShiftも押しながらペーストしてみてください)

$ bunx wrangler secret put DATABASE_URL
 ⛅️ wrangler 3.109.2
--------------------

√ Enter a secret value: ... ****************************************************************************************
🌀 Creating the secret for the Worker "backend"
✨ Success! Uploaded secret DATABASE_URL

Success! Uploaded secret DATABASE_URLと表示されたら成功です。

最後に、フロントエンド側でバックエンドのURLを環境変数に設定します。
Cloudflare Pagesのサイトにアクセスして設定します。
https://dash.cloudflare.com/sign-up/workers-and-pages

  1. フロントエンドのプロジェクトを選択
  2. タブから設定を選択→変数とシークレットを選択
  3. 変数とシークレット欄の+追加を押す
  4. 変数名にNEXT_PUBLIC_API_URLを入力し、値には先ほど表示されたURLを入力します。

保存を押す→タブからデプロイに移動する→一番上のデプロイの3点リーダーを押してデプロイを再試行を押します。

デプロイが完了したら、フロントエンドのURLにアクセスしてみましょう。すると、本番環境でもTODOの表示、追加ができるようになっているはずです!!

おわりに

お疲れさまでした!!!本記事ではNext.js + Hono + Bunのモノレポ構成で型安全にWebアプリケーションを作成する方法を説明しました。簡単なTODOアプリを作成しTODOの表示・追加の機能を実装しましたが、削除や編集、認証機能、エラーハンドリングなどまだまだやれることがあるので、興味のある方はぜひやってみてください!

また、本記事の内容に関して誤りや改善点があれば、ぜひ教えていただけると幸いです。

補足

GitHub

本記事で作成したコードは以下のリポジトリで公開しています。
https://github.com/y4asse/next-hono-bun-monorepo

Next.jsでHonoを使う際の注意点

本記事ではNext.jsをStatic exportsでSPA(Single Page Application)として構築しましたが、本来Next.jsではRoute Handlersという機能があります。この機能を使用することで、別途でAPIサーバーを必要とせず、Next.js上でAPIリクエストを処理することができます。さらにRoute Handlers上でHonoを使用してRPCで通信を行うことことも可能です。
https://zenn.dev/chot/articles/e109287414eb8c

Full-Stack TypeScript

本記事で扱ったように、フロントやバックエンド等の開発言語をTypeScriptで統一し、型安全に開発をする技術構成のことを「Full-Stack TypeScript」という名称に統一する活動があるみたいです。
https://zenn.dev/ascend/articles/full-stack-typescript

また、このようなFull-Stack TypeScriptで構築された技術スタックにT3 StackJStackなどがあります。興味がある人は、見てみるといいかもしれません👀
https://create.t3.gg
https://jstack.app/

なぜReact+ViteではなくNext.js Static exports?

本記事ではNext.jsのStatic exportsを使用してSPAとして構築しました。RSCやServer Actionsを使わないならReact+Viteでよいのでは?と思う方もいるかもしれません。その通りです。サーバーサイドでの処理が必要ないなら選択しとしてはReact+Viteの構成でもかまいません。しかし、個人的にNext.jsのファイルベースのルーティングを好んで使用していたというのと、Next.jsを採用していた方が、後にサーバーサイドレンダリングをしたくなったときに移行しやすいといったメリットがあると思います。この辺はプロジェクトの特性によって変えるのが良さそうです。

85

Discussion