[入門] Next.js+Hono+Bunのモノレポ構成で型安全なWebアプリ開発をする
こんにちは、やせです。
普段はゆるく個人開発をしている学生です。
はじめに
本記事では、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
モノレポでプロジェクトを管理することで、可視性の向上やコードの共有が簡単になる等のメリットがある一方で、コード変更による影響範囲の増加や依存関係の複雑化等のデメリットがあります。
以下にモノレポのメリットとデメリットをまとめました。
プロジェクトやチームの特性を考えてどちらの管理方式を採用するのかが大事なポイントだと思います。
参考資料👇
Bunとは
Bunは高速なJavaScriptのランタイムで、Node.jsやDenoの代替として注目されています。特徴としては以下のようなものがあります。
- 高速な実行速度
- バンドラー、テスト、ランナー、パッケージマネージャーを完備
- Node.jsとの高い互換性
- TypeScript & JSXをネイティブサポート
- etc...
公式ページ👇
個人的には、NodeからBunに移行したことによって、CI/CDの時間が劇的に短縮され、開発体験がすごく向上したと感じています。
Bun Workspacesとは
次に、Bun Workspacesについて説明します。
Bun Workspacesとは、複数のプロジェクトのパッケージを、ルートディレクトリで管理できる機能です。
一般的に、モノレポ構成では依存関係の管理が複雑になりますが、Workspaces機能を使うことで、依存関係管理が簡単になります。Workspacesではルートでnode_modules
を管理するので、プロジェクトごとにnode_modules
を持たなくて済みます。また、共通の依存バージョンを一元管理し、バージョンの不一致を防止することもできます。
参考資料👇
また、同様のツールとして以下のようなものがあります。
🚀ハンズオン
実際にBunでWorkspacesを構築する方法を説明します。
はじめに、以下のページからBunをインストールします。
以下のコマンドでバージョンが正しく表示されれば大丈夫です。本記事ではバージョン1.2.2
を使用します。
$ bun -v
1.2.2
次に、作業するディレクトリを作成します。プロジェクト名はsample-app
を使用しますが、適宜変更してください。
$ mkdir sample-app
$ cd sample-app
以下のpackage.json
ファイルをルートフォルダ直下に作成します。
workspaces
にapps/*
を指定することでapps
直下にあるディレクトリがワークスペースとして認識されます。
{
"name": "sample-app",
"private": true,
"workspaces": [
"apps/*"
]
}
.gitignore
ファイルを作成しておきます。
node_modules
必要なディレクトリを作成しておきます。中身は後で実装していきます。
$ mkdir apps
参考資料👇
HonoのRPC機能
Honoとは
HonoのRPC機能について説明する前に、軽くHonoについて説明します。
Honoは軽量で高速なJS/TSのWebフレームワークであり、Cloudflare Workersのようなエッジ環境で動くのが大きなポイントです。
エッジ環境とは、ユーザーに近い場所に分散配置されたサーバー(エッジサーバー)を中心としたコンピューティング環境のことをいいます。従来は、一か所のサーバーですべてのリクエストを処理する中央集権的なやり方が一般的でした。しかし、この方法だとサーバーに負荷が集中してしまったり、サーバーから離れているユーザーのレイテンシが大きくなってしまうといった問題がありました。
そこで各地に配置されたエッジサーバー上でリクエストを処理するといった考えが普及していきました。ユーザーから一番近いエッジサーバーにリクエストを送るため、低遅延・低負荷を実現できるようになりました。
Honoは、このエッジサーバー上で動作することが大きな特徴となっています。
また、Honoは国産のフレームワークとなっており、Yusuke Wada さんが作っています🔥
こちらの記事でご本人様が詳しく説明しています👇
HonoのRPC!
次にHonoのRPC機能について説明します。RPC機能を使用することで、フロントエンドとバックエンド間の通信を型安全に行うことができます。
こちらもご本人様が詳しく説明しています👇
一般的にフロントエンドからバックエンドに通信を行うとき、以下のコードのように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_modules
とbun.lock
が作成されます。このようにWorkspacesを使用することで、ルートディレクトリでnode_modules
が管理されます。
sample-app
├── bun.lock
├── node_modules
│ ├── backend # backendフォルダへのシンボリックリンクが作成される
│ └── frontend # frontendフォルダへのシンボリックリンクが作成される
├── package.json
└── apps
├── backend
└── frontend
バックエンドのファイルを編集します。ここではroutes
の型をexportすることでフロント側で使えるようにしています。また、フロントからAPIを叩けるようにCORSを設定しています。
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
のようにパスを指定することができます。
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
を設定します。
NEXT_PUBLIC_API_URL=http://localhost:8080
page.tsx
ファイルを編集します。ボタンを押すとRPCでAPIサーバーへリクエストを送り、アラートで表示するコードです。
'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を削除しておきます。
@import "tailwindcss";
ここで、apps/frontend/src/app/page.tsx
ファイルのdata.message
を見てみると、RPC機能により、バックエンドで定義した型が付いていることが確認できると思います。このようにしてRPC機能を使用すると、バックエンドで作成されたAPIのコードから型が自動で作成され、フロントエンドでその型を使用することができます。
サーバーを起動して動作確認したいのですが、その前に、バックエンドのpackage.json
を編集して起動するポートを変えておきます。また、wrangler.jsonc
も編集してNode.js互換モードを有効にするフラグを追加しておきます。これをやっておかないと後々動作しません。
{
"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"
}
}
{
"$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ファーストのスキーマ宣言およびバリデーションライブラリです。
公式ドキュメントによると、以下のような特徴があります。
- 開発者に使いやすい設計
- スキーマから自動で型推論
- 依存関係ゼロ
- Node.jsとすべての最新ブラウザで動作
- 軽量(8kb minified + zipped)
- イミュータブル
- 関数型アプローチ
このZodを使用することで、スキーマを定義すると自動で型推論を行ってくれて、型安全にバリデーションを行うことができ、ランタイムエラーのリスクを減らすことができます。
例として、ユーザー名をバリデーションを行うコードを考えます。
以下のように、単なるバリデーションを行うだけでは、data.name
型はany
のままになります。
function processUser(data: any) {
if (typeof data.name !== "string") {
throw new Error("Invalid name");
}
return data.name.toUpperCase(); // data.name の型は "any" のまま
}
ここで、Zodを使用してスキーマを宣言し、解析を行うことでuser
にはスキーマで宣言したデータ構造が入るため、user.name
はstring
型となり、これ以降、user
を安全に使用することでき、ランタイムエラーを回避しつつ開発を行うことができます。
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は関数型の思想が取り入れられており、こちらの記事で詳しく解説されています👇
Hono + Zod middlewareでバリデーション
Honoでは豊富なミドルウェアが用意されており、Zodにもミドルウェアが用意されています。
ミドルウェアを通すことで、Zodで定義したスキーマをもとに、リクエストデータを簡単にバリデーションして、型安全にリクエストデータを扱うことができます。
サンプルコードを以下に示します。
POSTメソッドでname
をリクエストデータに含めると"Hello 名前"のように返ってきます。
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
のファイルを編集していきます。
title
とdescription
を受け取るtodo
というエンドポイントを作成します。バリデーションの内容は以下のようにします。
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にリクエストを送り、エラーがあればエラーメッセージを表示します。
'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なのでセルフホスティングできたりと、とても良いサービスだと思います。
参考資料👇
Drizzle ORMとは
Drizzle ORMとは、TypeScript/JavaScript向けの軽量で型安全なORMライブラリです。Drizzleを使用することで、データベーススキーマをTypeScriptで記述することができたり、型安全にSQLを実行することができます。
特徴としては、以下のようなものがあります。
- 型安全にSQLを実行できる
- SQL構文とほぼ同じ書き方(「SQLを知っていれば、Drizzleも分かる」と謳っている)
- 軽量(7.4kb minified+gzipped)
- 依存関係がゼロ
- 豊富なサーバーフル・サーバーレスランタイムをサポート
- エッジサーバーやブラウザなどのあらゆるランタイムで動作
参考資料👇
個人的に、このふざけた感じが結構好きです笑
他にも、TypeScriptには以下のようなORMやクエリビルダーがあります👇
Drizzleを使用したマイグレーション
マイグレーションとは、データベースのスキーマやデータを変更・移行するプロセスです。
DBのスキーマに新しいカラムを追加・変更したいときに、どのようなカラムが追加されるのか削除されるのか、といった変更内容を記述したファイルを作成し、そのファイルの内容を実行してDBのスキーマを変更するという方法です。
Drizzleではマイグレーション機能が提供されており、drizzle-kitのツールを使用することで、マイグレーションを簡単に実行することができます。
参考資料👇
🚀ハンズオン
それでは、Supabase DBとDrizzle ORMを使用して、データベース操作を行います。
以下のページから、Supabaseのプロジェクトを作成します。
New Project→your's Orgを押して、プロジェクト名とパスワードを入力します。リージョンはTokyoにしておきます。パスワードは後で使用するので保存しといてください。入力したらCreate new projectを押してプロジェクトを作成します。
Connectを押します。
Direct connectionのURIをコピーしたら、バックエンドプロジェクトに.dev.vars
ファイルを作成して、DATABASE_URL
にコピーしたURIを記述します。[PASSWORD]
は先程保存したパスワードに変更しておきます。
DATABASE_URL=コピーしたURI
次に、Drizzleをインストールしておきます。
$ cd apps/backend
$ bun add drizzle-orm postgres dotenv
$ bun add -D drizzle-kit tsx @types/node
Drizzleを使用してスキーマを定義します。
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
を作成します。
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
以下のようなマイグレーションファイルが作成されますが、作成されるファイル名が同じにならないことに注意してください。
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を使用してデータベースにデータを追加する処理と、取得する処理を実装します。
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を使用します。
TanStack Queryは、Reactのコンポーネント内でデータをフェッチ、キャッシュ、更新などができるライブラリです。
以下の本で詳しく説明されているので、詳しく知りたい方は読んでみると良いかもしれません👇
TanStack Queryを以下のコマンドでインストールします。
$ cd apps/frontend
$ bun add @tanstack/react-query
TanStack Queryを使用するためには、ルートレイアウトでプロバイダーを設定する必要があります。そのためプロバイダーを作成し、apps/frontend/src/app/layout.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>
)
}
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
を使用しています。
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
を使用しています。これを用いることで、データのキャッシュや更新を管理してくれます。
'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
'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は、以下のような特徴があります。
個人的には、無料枠が充実しているのがとても推しポイントです👇
参考資料
他にもデプロイ先にVercelというNext.jsを作成している企業のサービスがあります。
こちらもNext.jsアプリを無料でデプロイすることができ、様々なフレームワークにも応しています。Cloudflare Pages同様、GitHubレポジトリと連携し、CI/CDを行うことができたり、静的コンテンツをCDNのエッジサーバーから配信してくれるといった特徴がります。ただし、注意点として無料プランだと商用利用ができないので注意が必要です。
🚀ハンズオン
それでは、作成したNext.jsアプリをCloudflare Pagesにデプロイします。本記事ではNext.jsをStatic exportsして静的サイトとしてデプロイします。Static exportsすることで、Next.jsアプリを静的ファイルとしてビルドすることができます。
設定ファイルを編集してNext.jsアプリをStatic exportsにします。これにより、静的ファイルとしてビルドされます。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
+ output: 'export',
};
export default nextConfig;
次にsample-app
をGitHubレポジトリを作成してプッシュしておきます。(やり方省略)
Cloudflare Pagesのサイトにアクセスします。
作成を押す→タブのPagesを選択→Gitに接続 を選択します。
GitHubアカウントを連携させて、先ほど作成したリポジトリを選択し、セットアップの開始を押します。すると以下のような画面になるはずです。
各入力項目を以下のように入力します。
フレームワークプリセット:Next.js(Static HTML Exports)
ビルドコマンド:bun install && bun run build
ビルド出力ディレクトリ:out
ルートディレクトリ(アドバンスト)
・パス:apps/frontend
環境変数(アドバンスト)
・BUN_VERSION
:1.2.2
・NODE_VERSION
:20.9.0
「保存してデプロイする」を押します。するとビルドが始まり、ビルドが完了するとデプロイされます。「○○でプレビューできます」とURLが表示されるので、アクセスして以下のように表示されれば成功です。
APIサーバーをまだデプロイしていないため、今のままでは動きません。次章でAPIサーバーをデプロイし、本番環境でも動くようにします。
APIサーバーをCloudflare Workersにデプロイする
Cloudflare Workersとは
Cloudflare Workersは、Cloudflareのエッジ環境で動作するサーバーレスプラットフォームです。Cloudflareのグローバルネットワーク上のエッジ上で実行されるため、低遅延かつ高速なサービスを実現できます。こちらもCloudflare Pagesと同じく、無料枠が充実しているのも特徴です。
Cloudflare Workersについては、こちらの記事で詳しく解説されています👇
🚀ハンズオン
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のサイトにアクセスして設定します。
- フロントエンドのプロジェクトを選択
- タブから設定を選択→変数とシークレットを選択
- 変数とシークレット欄の+追加を押す
- 変数名に
NEXT_PUBLIC_API_URL
を入力し、値には先ほど表示されたURLを入力します。
保存を押す→タブからデプロイに移動する→一番上のデプロイの3点リーダーを押してデプロイを再試行を押します。
デプロイが完了したら、フロントエンドのURLにアクセスしてみましょう。すると、本番環境でもTODOの表示、追加ができるようになっているはずです!!
おわりに
お疲れさまでした!!!本記事ではNext.js + Hono + Bunのモノレポ構成で型安全にWebアプリケーションを作成する方法を説明しました。簡単なTODOアプリを作成しTODOの表示・追加の機能を実装しましたが、削除や編集、認証機能、エラーハンドリングなどまだまだやれることがあるので、興味のある方はぜひやってみてください!
また、本記事の内容に関して誤りや改善点があれば、ぜひ教えていただけると幸いです。
補足
GitHub
本記事で作成したコードは以下のリポジトリで公開しています。
Next.jsでHonoを使う際の注意点
本記事ではNext.jsをStatic exportsでSPA(Single Page Application)として構築しましたが、本来Next.jsではRoute Handlersという機能があります。この機能を使用することで、別途でAPIサーバーを必要とせず、Next.js上でAPIリクエストを処理することができます。さらにRoute Handlers上でHonoを使用してRPCで通信を行うことことも可能です。
Full-Stack TypeScript
本記事で扱ったように、フロントやバックエンド等の開発言語をTypeScriptで統一し、型安全に開発をする技術構成のことを「Full-Stack TypeScript」という名称に統一する活動があるみたいです。
また、このようなFull-Stack TypeScriptで構築された技術スタックにT3 StackやJStackなどがあります。興味がある人は、見てみるといいかもしれません👀
なぜReact+ViteではなくNext.js Static exports?
本記事ではNext.jsのStatic exportsを使用してSPAとして構築しました。RSCやServer Actionsを使わないならReact+Viteでよいのでは?と思う方もいるかもしれません。その通りです。サーバーサイドでの処理が必要ないなら選択しとしてはReact+Viteの構成でもかまいません。しかし、個人的にNext.jsのファイルベースのルーティングを好んで使用していたというのと、Next.jsを採用していた方が、後にサーバーサイドレンダリングをしたくなったときに移行しやすいといったメリットがあると思います。この辺はプロジェクトの特性によって変えるのが良さそうです。
Discussion
すごくほしかった構成の記事でした!助かりました👏