🔥

ハンズオン!Next.js+Hono+RPC+Supabase+Drizzle+pnpm+Turborepoで作るモノレポ構成のアプリ開発

に公開

はじめに

プログラミングスクールRUNTEQでWebエンジニア兼講師をしているいっぺい(@ippei_111)と申します。

今回は、TypeScriptを使用して、Next.js+Hono+RPC+Supabase+Drizzle+pnpm+Turborepoのモノレポ構成のアプリを作成する方法をハンズオン形式で紹介していきます。

本記事で作成するアプリは、フロントエンドはNext.jsのApp Router、バックエンドはHonoフレームワークを使用し、Cloudflare Workersにデプロイします。データベースはSupabaseのPostgreSQLを利用し、ORM/クエリビルダーとしてDrizzleを採用しています。モノレポ構成のプロジェクト管理にはpnpm WorkspaceとTurborepoを使用します。

Honoとは

Honoは、Cloudflare Workers、Deno、Bun、Node.jsなど様々な環境で動作する、高速で軽量なWeb APIフレームワークです。
エッジコンピューティング環境(Cloudflare Workersなど)と相性が良く、パフォーマンスを重視したWebアプリケーション開発に適しています。

◼︎ Honoの特徴

  • 超軽量 : 最小限の依存関係とシンプルなAPI設計により、非常に軽量です。
  • 高速 : ルーティングやレスポンス処理が非常に高速で、パフォーマンスが優れています。
  • TypeScriptファーストの設計 : TypeScriptでの開発を前提に設計されており、型安全なAPIを提供します。
  • 複数環境互換 : Cloudflare Workers、Deno、Bun、Node.jsなど、様々な環境で動作します。

https://hono.dev/

Hono RPCとは

Hono RPCは、HonoフレームワークにおけるRPC(Remote Procedure Call)機能を提供する拡張機能です。この機能により、フロントエンドとバックエンド間の通信をより型安全かつ効率的に行うことができます。

使用方法としては、サーバー側でHono RPCを使用してエンドポイントを定義し、クライアント側ではこれに対応する型付きのクライアントを使用します。これにより、API呼び出しの型エラーをビルド時に検出でき、開発時の安全性と効率が向上します。

モノレポ構成との相性も良く、バックエンドで定義した型やインターフェースをフロントエンドでそのまま利用できるため、両者間で型の一貫性を保ちやすいというメリットがあります。

◼︎ Hono RPCの特徴

  • 型安全な通信 : TypeScriptの型情報を使用して、フロントエンドとバックエンド間の通信を型安全に行うことができます。
  • 自動生成されたクライアント : APIエンドポイントに対応するクライアントコードを自動生成してくれます。
  • バリデーション統合 : Zodなどのバリデーションライブラリと統合されており、リクエストやレスポンスのバリデーションを簡単に行うことができます。

https://hono.dev/docs/guides/rpc

pnpm Workspaceとは

pnpm Workspaceは、pnpmパッケージマネージャーが提供するモノレポ管理機能です。単一のリポジトリ内で複数のプロジェクト(パッケージ)を効率的に管理できます。

設定方法は非常にシンプルで、ルートディレクトリにpnpm-workspace.yamlファイルを作成し、ワークスペースとして扱うディレクトリパターンを指定するだけです。これにより、例えばフロントエンドとバックエンドのプロジェクトを同一リポジトリで管理しながら、タイプやコードの共有を容易に行うことができます。

https://pnpm.io/ja/workspaces

Turborepoとは

Turborepoは、JavaScriptとTypeScriptのモノレポを高速化するためのビルドシステムです。Vercelによって開発されており、大規模なコードベースでのビルド、テスト、リントなどのタスク実行を最適化します。

https://turbo.build/

アプリケーションの概要

完成系のイメージ

概要

今回は、ハンズオン形式で、Next.js+Hono+RPC+Supabase+Drizzle+pnpm+Turborepoのモノレポ構成のアプリを作成することを目的とするため、基本的な記事のCRUD機能を持つアプリケーションを開発します。

※ 本記事は筆者がmacOSで開発を行っているため、macOSを前提にした内容になっています。

やらないこと

  • 認証機能
  • テストコード

アーキテクチャ

  • モノレポ構成
  • フロントエンド : Next.js App Routerを使用して、Vercelにデプロイ
  • バックエンド : Honoを使用して、Cloudflare Workersにデプロイ
  • データベース : Supabase
  • ORM/クエリビルダー : Drizzle
  • プロジェクト管理 : pnpm WorkspaceとTurborepo

ディレクトリ構成

ディレクトリ構成は以下を想定しています。

/
├── .turbo/
├── node_modules/
├── packages/
│   ├── api/                     # Honoバックエンド
│   │   ├── src/
│   │   │   ├── routes/          # Honoルーティング
│   │   │   ├── schemas/         # Zodスキーマ
│   │   │   └── types/           # 共通の型定義
│   │   ├── supabase/
│   │   ├── .dev.vars
│   │   ├── .prod.vars
│   │   ├── drizzle.config.ts
│   │   └── wrangler.jsonc
│   │
│   └── frontend/                # Next.jsフロントエンド
│       ├── src/
│       │   ├── app/             # Next.js App Router
│       │   ├── services/        # サーバーコンポーネントで使用するAPI通信
│       │   └── lib/             # クライアント用ユーティリティ
│       ├── public/
│       └── middleware.ts
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.json
└── turbo.json
  • node_modules/ : プロジェクト全体の共有依存関係
  • package.json : ルートレベルの依存関係と共通スクリプトを定義
  • pnpm-workspace.yaml : pnpmのワークスペース設定
  • turbo.json : Turborepoの設定
  • packages/api : Honoバックエンドのソースコードを格納するディレクトリ
    • src/routes : Honoのルーティングを格納するディレクトリ
    • src/schemas : Zodスキーマを格納するディレクトリ
    • src/types : 共通の型定義を格納するディレクトリ
    • supabase : Supabaseローカル開発環境の設定ファイルを格納するディレクトリ
    • wrangler.jsonc : Cloudflare Workersの設定ファイル
  • packages/frontend : Next.jsフロントエンドのソースコードを格納するディレクトリ
    • src/services : サーバーコンポーネントで使用するAPI通信を格納するディレクトリ
    • src/lib : APIクライアントを格納するディレクトリ

技術スタック

今回、使用する技術スタックは以下の通りになります。

カテゴリ 技術
フロントエンド Next.js 15.2.4 / React 19.0.0 / TypeScript 5.0
バックエンド Hono 4.7.5
データベース Supabase / Drizzle ORM 0.41.0
プロジェクト管理 pnpm 10.6.3 / Turborepo 2.4.4
デプロイ Vercel / Cloudflare Workers

モノレポ構成を採用した理由

モノレポ(Monorepo)は、複数のプロジェクトを1つのリポジトリで管理する手法です。今回は、フロントエンド(Next.js)とバックエンド(Hono)を同じリポジトリで管理することで、型やバリデーションの共有を容易にし、開発効率を向上させていきたいと考え、モノレポ構成を採用しました。

🙆‍♂️ モノレポ構成を採用するメリット

モノレポのメリットについて、技術的な観点で感じたことを以下にまとめてみました。

◼︎ 型共有
今回、TypeScriptを使用してフロントエンドとバックエンドを開発するため、APIレスポンスの型や入力値の型をフロントエンドとバックエンドで共有することで、型不一致によるバグを防ぐことができます。

◼︎ LintやFormatの一元管理
ESLintやPrettierなどの設定ファイルをルートに配置することで、全てのプロジェクト・パッケージで同じルールを適用することができます。

◼︎ issueやPull Requestの一元管理
今回のアプリケーションのように、フロントエンドとバックエンドにまたがる開発を行う場合、1つのリポジトリでissueやPull Requestを管理することは開発の管理を容易にします。

🙅‍♂️ モノレポ構成を採用するデメリット

モノレポのデメリットとしては、プロジェクトが大きくなるにつれてリポジトリサイズが大きくなり、プロジェクト間の依存関係が複雑になってしまう可能性があります。

サーバレスアーキテクチャを採用した理由

サーバーレスアーキテクチャは、サーバー管理や運用をプロバイダーが行うため、開発者はサーバーについて考える必要がなくなり、アプリケーションのコードに集中することができるようになるアーキテクチャです。
今回、バックエンドのHonoの実行環境として、Cloudflare Workersを選択した理由としては、個人開発のアプリケーションになるため、できるだけ運用コストを抑えたかったのが1番の理由です。
Cloudflare Workersは無料プランがあり、2025年4月現在では「1日あたり10万リクエスト、1分あたり1,000リクエスト」まで無料で使用することができます。

https://developers.cloudflare.com/workers/platform/pricing/

🙆‍♂️ サーバレスアーキテクチャを採用するメリット

◼︎ 使用したリソースのみ課金される
無料枠が提供されているかつ、従量課金制になるため、使用した分だけ費用が発生します。そのため、運用コストを予測しやすく、小規模プロジェクトでは無料で運用することが可能です。

◼︎ 自動スケーリング
トラフィックの量に応じて自動的にスケールしてくれるため、スケーリングに関する運用負荷を軽減することができます。

🙅‍♂️ サーバレスアーキテクチャを採用するデメリット

◼︎ コールドスタート
長時間リクエストがされていない関数が呼び出されると、初期化に時間がかかることがあります。

◼︎ 実行時間に制限がある
多くのサーバーレスプロバイダーには、実行時間の制限があります。

ハンズオン

では、さっそくハンズオン形式でアプリケーションを実際に作成していきます。

1. 環境準備

まず、必要なツールをインストールして開発環境を整えます。
(既にインストール済みの方は、次のステップに進んでください)

前提条件

  • Node.js v20.5.1以上がインストールされていること
  • pnpm v10.6.3以上がインストールされていること

pnpmのインストールがまだの方は、公式ドキュメントを参考にしてインストールを行なってください。
https://pnpm.io/ja/installation

2. モノレポ構成の構築

👨‍💻 プロジェクトの初期化

まず、pnpmを使用してpackage.jsonの初期化を行います。

$ pnpm init

ルートディレクトリにpackage.jsonが作成されていると思いますので、以下のように変更してください。

{
  "name": "hono-nextjs-monorepo-zenn",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "echo \"Error: no test specified\" && exit 1",
    "lint": "turbo run lint"
  },
  "devDependencies": {
    "prettier": "^3.5.1",
    "turbo": "2.4.4",
    "typescript": "^5.8.0"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.7.0"
}
  • dev : 開発サーバーを起動するためのスクリプト
  • build : ビルドを行うためのスクリプト

👨‍💻 pnpm Workspaceの設定

プロジェクトをpnpm Workspaceとして設定するために、ルートディレクトリにpnpm-workspace.yamlを作成します。

packages:
  - 'packages/*'

この設定により、packagesディレクトリ内のすべてのパッケージがpnpm Workspaceとして認識されます。

👨‍💻 Turborepoの設定

Turborepoを設定するために、ルートディレクトリにturbo.jsonを作成します。

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {},
    "format": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    }
  }
}
  • globalDependencies : 全タスクの依存ファイルを指定。環境変数ファイルが変更されるとタスクが再実行されます。
  • tasks : プロジェクトで実行可能なタスクを定義します。
    • build.dependsOn : ビルド前に完了すべき依存タスクを指定。^buildは「ワークスペース依存関係のビルドを先に実行」を意味します。
    • build.outputs : ビルドで生成されるファイル・ディレクトリを指定。キャッシュ対象となります。
    • dev.cache: false : 開発サーバーの結果はキャッシュしないように設定しています。
    • dev.persistent: true : 開発サーバーが常駐プロセスとして実行されることを示します。

これらの設定により、パッケージ間の依存関係を考慮したビルドやキャッシュが可能になります。

👨‍💻 共通のTypeScript設定

ルートディレクトリにtsconfig.jsonを作成します。

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ]
  },
  "exclude": ["node_modules"]
}

こちらの詳細なオプションについては、公式ドキュメントを参考にしてください。
https://www.typescriptlang.org/tsconfig/

3. バックエンドアプリケーションの構築(Hono)

👨‍💻 Honoのインストール

packagesディレクトリにHonoアプリケーションを構築します。
バックエンドアプリケーション名はapiとします。

$ cd packages
$ pnpm create hono@latest

👇Hono - Quick Start
https://hono.dev/docs/#quick-start
pnpm create hono@latestを実行すると、Honoの初期設定に関する質問がいくつか表示されるので、以下のように回答してください。

✅ Target directory : api
✅ Which template do you want to use? : cloudflare-workers
✅ Do you want to install project dependencies? : Yes
✅ Which package manager do you want to use? : pnpm

これだけで、Honoアプリケーションのインストールが完了します。
次に、ルートディレクトリに戻り、pnpm devを実行して、Honoアプリケーションが正しく動作するか確認してください。

$ cd ..
$ pnpm dev

👇ターミナル

http://localhost:8787 にアクセスして、以下のような画面が表示されれば成功です。

4. フロントエンドアプリケーションの構築(Next.js)

👨‍💻 Next.jsのインストール

次に、フロントエンドアプリケーションを構築します。
フロントエンドアプリケーション名はfrontendとします。

$ cd packages
$ npx create-next-app@latest

👇Next.js - Automatic installation
https://nextjs.org/docs/app/getting-started/installation#automatic-installation
npx create-next-app@latestを実行すると、Next.jsの初期設定に関する質問がいくつか表示されるので、以下のように回答してください。

✅ What is your project named? : frontend
✅ Would you like to use TypeScript? : Yes
✅ Would you like to use ESLint? : Yes
✅ Would you like to use Tailwind CSS? : Yes
✅ Would you like your code inside a src/ directory? : Yes
✅ Would you like to use App Router? (recommended) : Yes
✅ Would you like to use Turbopack for next dev? : Yes
✅ Would you like to customize the import alias (@/* by default)? : No

ルートディレクトリに戻り、一度開発サーバーを停止してから、再度pnpm devを実行してください。

$ pnpm dev

http://localhost:3000 にアクセスして、Next.jsの初期画面が表示されれば成功です。

これで、モノレポ構成でバックエンド(Hono)とフロントエンド(Next.js)のアプリケーションがそれぞれ構築することができました。

5. DBのセットアップ(Supabase / Drizzle)

👨‍💻 Supabaseのローカル環境セットアップ

次に、Supabaseのローカル環境をセットアップしていきます。
Supabaseは、PostgreSQLデータベース、認証、ストレージなどの機能を提供するBaaSプラットフォームです。
https://supabase.com/

◼︎ Supabase CLIのインストール
まず、Supabase CLIをインストールします。Supabase CLIを使用することで、ローカル環境でSupabaseを実行し、開発することができます。

$ brew install supabase/tap/supabase

👇こちらの公式ドキュメントにもセットアップ方法が記載されています。
https://supabase.com/docs/guides/local-development/cli/getting-started?queryGroups=access-method&access-method=postgres

👇Supabase CLIは、ローカル開発にDockerコンテナを使用しますので、Dockerのインストールと設定が完了していない方は公式ガイドを参考にしてください。
https://docs.docker.com/desktop/

◼︎ Supabaseプロジェクトの初期化
次に、Supabaseプロジェクトを初期化します。

$ cd packages/api
$ supabase init

これにより、Supabaseの設定ファイルが生成されます。supabaseディレクトリ内のconfig.tomlファイルには、Supabaseの設定が記述されています。

◼︎ Supabaseローカル環境の起動
Supabaseのローカル環境を起動します。
packages/apiディレクトリで以下のコマンドを実行してください。

$ supabase start

このコマンドを実行すると、ローカル環境でSupabaseが起動します。初回実行時はDockerイメージのダウンロードが行われるため、少し時間がかかります。
👇起動が完了すると、以下のような情報が表示されます。

Started supabase local development setup.

         API URL: http://localhost:54321
          DB URL: postgresql://postgres:postgres@localhost:54322/postgres
      Studio URL: http://localhost:54323
    Inbucket URL: http://localhost:54324
        anon key: eyJh......
service_role key: eyJh......

これらの情報は後で使用するので、メモしておいてください。

ローカル環境で起動しているPostgresインスタンスには、DB URLにアクセスすることで接続できます。

◼︎ 環境変数の設定
packages/apiディレクトリに、.dev.varsというファイルを作成します。
作成した.dev.varsファイルに、以下の内容を記述してください。

DATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres
APP_FRONTEND_URL=http://localhost:3000
NODE_ENV=development

👨‍💻 DrizzleのDBスキーマ設定

Drizzleを使用してデータベースのスキーマを設定します。
Drizzleは、TypeScriptに特化して設計されたORMで、SQLに近い構文を使用してデータベース操作を行うことができます。
https://orm.drizzle.team/

◼︎ Drizzleのインストール
packages/apiディレクトリで以下のコマンドを実行して、Drizzleをインストールします。

$ pnpm add drizzle-orm postgres
$ pnpm add -D drizzle-kit dotenv dotenv-cli

👇Supabase×Drizzleセットアップ方法について
https://supabase.com/docs/guides/database/drizzle

packages/apiディレクトリに、drizzle.config.tsファイルを作成します。

import { defineConfig } from "drizzle-kit";
import * as dotenv from "dotenv";

dotenv.config();

try {
  dotenv.config({ path: "./.dev.vars" });
} catch (error) {
  console.log("No .dev.vars file found");
}

const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
  throw new Error("DATABASE_URL is not set");
}

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/db/schema.ts",
  out: "./src/db/migrations",
  dbCredentials: {
    url: DATABASE_URL,
  },
});

◼︎ DBスキーマの作成
データベースのスキーマファイルを作成します。
packages/api/src/dbディレクトリを作成し、その中にschema.tsというファイルを作成します。
packages/api/src/db/schema.tsファイルには、以下のスキーマ定義を行なってください。

import { pgTable, serial, text, varchar, timestamp } from "drizzle-orm/pg-core";

// 記事テーブル
export const articles = pgTable("articles", {
  id: serial("id").primaryKey(),
  title: text("title").notNull(),
  content: text("content").notNull(),
  slug: varchar("slug", { length: 255 }).notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export const schema = { articles };

// 型定義をエクスポート(フロントエンドと共有するため)
export type Article = typeof articles.$inferSelect;
export type NewArticle = typeof articles.$inferInsert;

次に、packages/api/package.jsonに、Drizzle実行スクリプトを追加します。

"scripts": {
  "dev": "wrangler dev",
  "deploy": "wrangler deploy",
  "db:generate": "drizzle-kit generate",
  "db:migrate": "drizzle-kit migrate",
  "db:studio": "drizzle-kit studio",
  "db:push:local": "dotenv -e .dev.vars -- drizzle-kit push",
  "db:push:prod": "dotenv -e .prod.vars -- drizzle-kit push"
}

◼︎ DBクライアントの設定
データベースのクライアント設定を行うために、packages/api/src/db/client.tsファイルを作成します。
クライアント設定とは、アプリケーションがデータベースと通信するために必要な接続情報やパラメータを定義することです。アプリケーションとデータベースの橋渡し的な役割を果たします。

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

export interface Env {
  DATABASE_URL: string;
  APP_FRONTEND_URL: string;
  NODE_ENV: string;
}

export function createClient(env: Env) {
  const connectionString = env.DATABASE_URL;

  const client = postgres(connectionString, {
    prepare: false,
    max: 1,
    idle_timeout: 20,
  });

  return drizzle(client);
}

また、Honoからデータベースクライアントを取得できるようにするためのヘルパー関数を作成しておきます。
packages/api/src/helpersディレクトリを作成し、その中にdbClient.tsファイルを作成します。

import { Context } from "hono";
import { createClient, Env } from "../db/client";

export const getDbClient = (c: Context<{ Bindings: Env }>) => {
  return createClient({
    DATABASE_URL: c.env.DATABASE_URL,
    APP_FRONTEND_URL: c.env.APP_FRONTEND_URL,
    NODE_ENV: c.env.NODE_ENV,
  });
};

◼︎ データマイグレーションの実行
スキーマ定義が完了したら、マイグレーションファイルを生成し、データベースに適用していきます。
packages/apiディレクトリで以下のコマンドを実行してください。

$ pnpm run db:generate
$ pnpm run db:push:local

これにより、ローカル環境のSupabaseデータベースにarticlesテーブルが作成されました。

http://localhost:54323 にアクセスして、ローカル環境のSupabase Studioにアクセスして、articlesテーブルが作成されていることを確認してください。

これで、Supabaseのローカル環境のセットアップが完了しました。

6. APIの実装(Hono)

次に、articlesのCRUD操作を行うAPIを実装していきます。

👨‍💻 Zodスキーマ定義

まず、APIリクエストのバリデーションを行うためのZodスキーマを定義します。
Zodは、ランタイムでの型チェックを行うライブラリで、TypeScriptと組み合わせて使用することで、型安全なバリデーションを行うことができます。
https://zod.dev/

◼︎ Zodのインストール

$ pnpm add zod

次に、@hono/zod-validatorをインストールします。

$ pnpm add @hono/zod-validator

@hono/zod-validatorは、HonoとZodを組み合わせて使用するためのバリデーションミドルウェアです。
これを使用することで、HonoのルーティングでZodを使用したバリデーションを簡単に行うことができます。
👇@hono/zod-validator
https://hono.dev/docs/guides/validation

◼︎ Zodスキーマの作成

packages/api/src/schemasディレクトリを作成し、その中にarticle.tsというファイルを作成します。

import { z } from "zod";

export const articleSchema = z.object({
  id: z.number().optional(),
  title: z.string().min(1, "タイトルは必須です").max(255, "タイトルは255文字以内で入力してください"),
  content: z.string().min(1, "内容は必須です"),
  slug: z
    .string()
    .min(1, "スラッグは必須です")
    .max(255, "スラッグは255文字以内で入力してください")
    .regex(/^[a-z0-9-]+$/, "スラッグは小文字英数字とハイフンのみ使用できます"),
    createdAt: z.union([
      z.preprocess((val) => (typeof val === "string" ? new Date(val) : val), z.date()),
      z.string(),
    ]).optional(),
    updatedAt: z.union([
      z.preprocess((val) => (typeof val === "string" ? new Date(val) : val), z.date()),
      z.string(),
    ]).optional(),
});

export const createArticleSchema = articleSchema.omit({ id: true, createdAt: true, updatedAt: true });
export const updateArticleSchema = articleSchema.partial().omit({ id: true, createdAt: true, updatedAt: true });

export type Article = z.infer<typeof articleSchema>;
export type CreateArticle = z.infer<typeof createArticleSchema>;
export type UpdateArticle = z.infer<typeof updateArticleSchema>;
  • articleSchema : 記事のスキーマを定義します。
  • createArticleSchema : 記事作成時のスキーマを定義します。
  • updateArticleSchema : 記事更新時のスキーマを定義します。
  • omit : 指定したフィールドを除外します。
  • partial : 指定したフィールドをオプショナルにします。
  • z.infer : ZodスキーマからTypeScriptの型を生成します。この設定により、別途型定義を行う必要がなくなり、フロントエンドとバックエンドで同じ型を使用することができます。

👨‍💻 記事のCRUD APIエンドポイント実装

packages/api/src/routesディレクトリを作成し、その中にarticles.tsファイルを作成してください。

◼︎ 記事の一覧取得

import { Hono } from "hono";
import { Env } from "../db/client";
import { getDbClient } from "../helpers/dbClient";
import { articles } from "../db/schema";

export const articlesRoutes = new Hono<{ Bindings: Env }>()
  // 記事一覧取得
  .get("/articles", async (c) => {
    const db = getDbClient(c);
    const allArticles = await db.select().from(articles);

    return c.json({
      success: true,
      data: allArticles,
    });
  })

◼︎ エントリーポイント設定
packages/api/src/index.tsファイルにエントリーポイントを設定します。

import { Hono } from "hono";
import { cors } from "hono/cors";
import { Env } from "./db/client";
import { articlesRoutes } from "./routes/articles";

const app = new Hono<{ Bindings: Env }>()
  .use(
    "/*",
    cors({
      origin: (origin, c) => {
        return c.env.APP_FRONTEND_URL || "http://localhost:3000";
      },
      allowHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
      allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
      exposeHeaders: ["Content-Length", "X-Kuma-Revision"],
      maxAge: 864_000,
      credentials: true,
    })
  )
  // APIルート登録
  .route("/api/", articlesRoutes);

export default app;
export type AppType = typeof app;
  • cors : CORSミドルウェアを使用して、フロントエンドからのリクエストを許可します。
  • origin : 許可するオリジンを指定します。環境変数APP_FRONTEND_URLが設定されている場合はそれを使用し、そうでない場合はhttp://localhost:3000を使用します。
  • .route("/api/", articlesRoutes) : /api/以下のルートに対して、articlesRoutesを登録します。articlesRoutesは、packages/api/src/routes/articles.tsで定義したルートです。この設定により、/api/articlesにアクセスすると、articlesRoutesが処理を行います。
  • export type AppType = typeof app : Honoアプリケーションの型をエクスポートします。これにより、他のファイルでHonoアプリケーションの型を使用することができます。
  • export default app : Honoアプリケーションをエクスポートします。これにより、Cloudflare WorkersでHonoアプリケーションを実行することができます。

◼︎ 記事一覧取得のエンドポイントを叩いてみる
pnpm devを実行して、Honoアプリケーションを起動して、 http://localhost:8787/api/articles リクエストを投げてみてください。

$ curl -X GET http://localhost:8787/api/articles

まだ、記事データを登録していないので、{"success":true,"data":[]}というレスポンスが返ってきたら成功です。

◼︎ 記事の詳細・作成・更新・削除
次に、記事の詳細取得、作成、更新、削除のエンドポイントも実装していきます。

import { Hono } from "hono";
import { Env } from "../db/client";
import { getDbClient } from "../helpers/dbClient";
import { articles } from "../db/schema";
import { eq } from "drizzle-orm";
import { zValidator } from "@hono/zod-validator";
import { createArticleSchema, updateArticleSchema } from "../schemas/article";

export const articlesRoutes = new Hono<{ Bindings: Env }>()
  // 記事一覧取得
  .get("/articles", async (c) => {
    const db = getDbClient(c);
    const allArticles = await db.select().from(articles);

    return c.json({
      success: true,
      data: allArticles,
    });
  })

  // 記事詳細取得
  .get("/articles/:slug", async (c) => {
    const db = getDbClient(c);
    const slug = c.req.param("slug");
    const article = await db
      .select()
      .from(articles)
      .where(eq(articles.slug, slug))
      .limit(1);

    if (!article.length) {
      return c.json(
        {
          success: false,
          message: "記事が見つかりません",
        },
        404
      );
    }

    return c.json({
      success: true,
      data: article[0],
    });
  })

  // 記事作成
  .post("/articles", zValidator("json", createArticleSchema), async (c) => {
    const articleData = c.req.valid("json");
    const db = getDbClient(c);

    // スラッグが既に存在するか確認
    const existingArticle = await db
      .select({ id: articles.id })
      .from(articles)
      .where(eq(articles.slug, articleData.slug))
      .limit(1);

    if (existingArticle.length) {
      return c.json(
        {
          success: false,
          message: "このスラッグは既に使用されています",
        },
        400
      );
    }

    const newArticle = await db.insert(articles).values(articleData).returning();

    return c.json({
      success: true,
      data: newArticle[0],
    });
  })

  // 記事更新
  .put("/articles/:slug", zValidator("json", updateArticleSchema), async (c) => {
    const articleData = c.req.valid("json");
    const slug = c.req.param("slug");
    const db = getDbClient(c);

    // 更新対象の記事が存在するか確認
    const existingArticle = await db
      .select({ id: articles.id })
      .from(articles)
      .where(eq(articles.slug, slug))
      .limit(1);

    if (!existingArticle.length) {
      return c.json(
        {
          success: false,
          message: "記事が見つかりません",
        },
        404
      );
    }

    // スラッグが変更される場合、新しいスラッグが既に使用されていないか確認
    if (articleData.slug && articleData.slug !== slug) {
      const duplicateSlug = await db
        .select({ id: articles.id })
        .from(articles)
        .where(eq(articles.slug, articleData.slug))
        .limit(1);

      if (duplicateSlug.length) {
        return c.json(
          {
            success: false,
            message: "このスラッグは既に使用されています",
          },
          400
        );
      }
    }

    const updatedArticle = await db
      .update(articles)
      .set({ ...articleData, updatedAt: new Date() })
      .where(eq(articles.slug, slug))
      .returning();

    return c.json({
      success: true,
      data: updatedArticle[0],
    });
  })

  // 記事削除
  .delete("/articles/:slug", async (c) => {
    const slug = c.req.param("slug");
    const db = getDbClient(c);

    const deletedArticle = await db
      .delete(articles)
      .where(eq(articles.slug, slug))
      .returning();

    if (!deletedArticle.length) {
      return c.json(
        {
          success: false,
          message: "記事が見つかりません",
        },
        404
      );
    }

    return c.json({
      success: true,
      data: deletedArticle[0],
    });
  });

◼︎ APIの動作確認
では、実際にAPIを叩いてみて動作確認を行います。

  • 記事作成 : POST /api/articles
$ curl -X POST http://localhost:8787/api/articles \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Test Article",
    "content": "This is a test article.",
    "slug": "test-article"
  }'
  • 記事一覧取得 : GET /api/articles
$ curl -X GET http://localhost:8787/api/articles
  • 記事詳細取得 : GET /api/articles/test-article
$ curl -X GET http://localhost:8787/api/articles/test-article
  • 記事更新 : PUT /api/articles/test-article
$ curl -X PUT http://localhost:8787/api/articles/test-article \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Updated Test Article",
    "content": "This is an updated test article.",
    "slug": "updated-test-article"
  }'
  • 記事削除 : DELETE /api/articles/test-article
$ curl -X DELETE http://localhost:8787/api/articles/updated-test-article

これで、記事のCRUD操作ができるAPIが完成です。

7. フロントエンドアプリケーションの実装(Next.js)

次に、フロントエンドからAPIを叩いて、記事の一覧表示、詳細表示、作成、更新、削除を行う機能を実装していきます。

👨‍💻 APIクライアントの作成

バックエンドAPIと通信するためのクライアントを設定します。
packages/frontend/src/libディレクトリを作成し、その中にclient.tsファイルを作成します。
client.tsファイルは、APIクライアントを定義するためのファイルです。APIクライアントは、バックエンドAPIと通信するためのインターフェースを提供します。

import { AppType } from "@api/index";
import { hc } from "hono/client";

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8787";

export const apiClient = hc<AppType>(API_BASE_URL);

◼︎ hono/clientのインストール
hono/clientは、Honoフレームワークのクライアントライブラリです。これを使用することで、Honoで定義したAPIエンドポイントに対して簡単にリクエストを送信することができます。
packages/frontendディレクトリで以下のコマンドを実行して、hono/clientをインストールしてください。

pnpm add hono

◼︎ TypeScriptのパスエイリアス設定
APIパッケージからのインポートパスを簡潔にするために、packages/frontend/tsconfig.jsonにパスエイリアスを設定します。これにより、APIパッケージの型定義を簡単に参照できるようになります。

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@api/*": ["../api/src/*"]
    }
  }
}

👨‍💻 環境変数の設定

packages/frontend/.env.localファイルを作成し、以下の内容を記述してください。

NEXT_PUBLIC_API_URL=http://localhost:8787

👨‍💻 記事一覧ページの実装

記事一覧画面を作成し、記事の一覧を表示します。
packages/frontend/src/app/articles/page.tsxファイルを作成し、以下の内容を記述してください。



import { getArticles } from "@/services/articles";
import Link from "next/link";

export default async function Home() {
  const { data: articles } = await getArticles();

  return (
    <main className="container mx-auto p-4">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">記事一覧</h1>
        <Link
          href="/articles/new"
          className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"
        >
          新規作成
        </Link>
      </div>

      {articles.length === 0 ? (
        <p className="text-gray-500">記事がありません。新しい記事を作成してください。</p>
      ) : (
        <div className="grid gap-4">
          {articles.map((article) => (
            <div key={article.id} className="border p-4 rounded shadow">
              <h2 className="text-xl font-semibold">{article.title}</h2>
              <p className="text-gray-600 mt-2 line-clamp-2">{article.content}</p>
              <div className="mt-4 flex gap-2">
                <Link
                  href={`/articles/${article.slug}`}
                  className="text-blue-500 hover:underline"
                >
                  詳細を見る
                </Link>
                <Link
                  href={`/articles/${article.slug}/edit`}
                  className="text-green-500 hover:underline"
                >
                  編集
                </Link>
              </div>
            </div>
          ))}
        </div>
      )}
    </main>
  );
}

◼︎ 記事一覧取得APIの実装
記事一覧を取得するためのAPIクライアントを作成します。
packages/frontend/src/app/services/articlesディレクトリを作成し、その中にindex.tsファイルを作成してください。

import { apiClient } from "@/lib/client";
import { Article } from "@api/schemas/article";

export async function getArticles(): Promise<{ success: boolean; data: Article[] }> {
  const response = await apiClient.api.articles.$get();
  return response.json();
}

curlコマンドで記事を作成してみましょう。

$ curl -X POST http://localhost:8787/api/articles \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Test Article",
    "content": "This is a test article.",
    "slug": "test-article"
  }'

http://localhost:3000/articles にアクセスして、以下の画面が表示されることを確認してください。

👨‍💻 記事詳細表示ページの実装

記事の詳細を表示するページを実装します。
packages/frontend/src/app/articles/[slug]/page.tsxファイルを作成し、以下の内容を記述してください。

import DeleteButton from "@/app/articles/_components/DeleteButton";
import { getArticle } from "@/services/articles";
import Link from "next/link";

export default async function ArticleDetail({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { success, data: article } = await getArticle((await params).slug);

  if (!success || !article) {
    return (
      <div className="container mx-auto p-4">
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
          記事が見つかりませんでした。
        </div>
        <div className="mt-4">
          <Link href="/articles" className="text-blue-500 hover:underline">
            記事一覧に戻る
          </Link>
        </div>
      </div>
    );
  }

  return (
    <div className="container mx-auto p-4">
      <article className="prose lg:prose-xl max-w-none">
        <h1>{article.title}</h1>
        <div className="text-gray-500 text-sm mb-6">
          作成日:{" "}
          {article.createdAt
            ? new Date(article.createdAt).toLocaleDateString()
            : "不明"}
          {article.updatedAt !== article.createdAt &&
            ` • 更新日: ${article.updatedAt ? new Date(article.updatedAt).toLocaleDateString() : "不明"}`}
        </div>

        <div className="whitespace-pre-wrap">{article.content}</div>
      </article>

      <div className="mt-8 flex gap-2">
        <Link
          href="/articles"
          className="bg-gray-200 hover:bg-gray-300 text-gray-800 py-2 px-4 rounded"
        >
          一覧に戻る
        </Link>
        <Link
          href={`/articles/${article.slug}/edit`}
          className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"
        >
          編集
        </Link>
        <DeleteButton slug={article.slug} />
      </div>
    </div>
  );
}

packages/frontend/src/app/articles/_components/DeleteButton.tsx

"use client";

import { deleteArticle } from "@/services/articles";
import { useRouter } from "next/navigation";
import { useState } from "react";

export default function DeleteButton({ slug }: { slug: string }) {
  const router = useRouter();
  const [isDeleting, setIsDeleting] = useState(false);

  const handleDelete = async () => {
    if (!confirm("本当にこの記事を削除しますか?")) {
      return;
    }

    setIsDeleting(true);
    try {
      const result = await deleteArticle(slug);
      if (result.success) {
        router.push("/articles");
        router.refresh();
      } else {
        alert("削除に失敗しました: " + result.message);
      }
    } catch (err) {
      console.error(err);
      alert("エラーが発生しました");
    } finally {
      setIsDeleting(false);
    }
  };

  return (
    <button
      onClick={handleDelete}
      disabled={isDeleting}
      className="bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded disabled:opacity-50"
    >
      {isDeleting ? "削除中..." : "削除"}
    </button>
  );
}

◼︎ 記事詳細取得と削除APIの実装
packages/frontend/src/services/articles/index.tsファイルに、記事詳細取得と削除APIのクライアントを追加します。

export async function getArticle(slug: string): Promise<{ success: boolean; data?: Article }> {
  const response = await apiClient.api.articles[":slug"].$get({
    param: { slug },
  });
  return await response.json();
}

export async function deleteArticle(slug: string): Promise<{ success: boolean; data?: Article }> {
  const response = await apiClient.api.articles[":slug"].$delete({
    param: { slug },
  });
  return await response.json();
}

http://localhost:3000/articles/test-article にアクセスして、記事の詳細が表示されることを確認してください。
また、記事の削除ボタンをクリックして、記事が削除されることを確認してください。
Image from Gyazo

👨‍💻 記事作成ページの実装

新しい記事を作成するためのフォームを実装します。
packages/frontend/src/app/articles/new/page.tsxファイルを作成してください。

"use client";


import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { createArticle } from "@/services/articles";

export default function NewArticle() {
  const router = useRouter();
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [slug, setSlug] = useState("");
  const [error, setError] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError("");
    setIsSubmitting(true);

    try {
      const result = await createArticle({ title, content, slug });
      if (result.success) {
        router.push("/articles");
        router.refresh();
      } else {
        setError(result.message || "記事の作成に失敗しました");
      }
    } catch (err) {
      setError("エラーが発生しました。しばらくしてからお試しください。");
      console.error(err);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-6">新規記事作成</h1>

      {error && (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
          {error}
        </div>
      )}

      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label htmlFor="title" className="block text-sm font-medium text-gray-700">
            タイトル
          </label>
          <input
            id="title"
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            required
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border"
          />
        </div>

        <div>
          <label htmlFor="slug" className="block text-sm font-medium text-gray-700">
            スラッグ
          </label>
          <input
            id="slug"
            type="text"
            value={slug}
            onChange={(e) => setSlug(e.target.value)}
            required
            pattern="[a-z0-9-]+"
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border"
          />
          <p className="text-sm text-gray-500 mt-1">
            小文字のアルファベット、数字、ハイフンのみ使用できます
          </p>
        </div>

        <div>
          <label htmlFor="content" className="block text-sm font-medium text-gray-700">
            内容
          </label>
          <textarea
            id="content"
            value={content}
            onChange={(e) => setContent(e.target.value)}
            required
            rows={10}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border"
          />
        </div>

        <div className="flex justify-end space-x-2">
          <Link
            href="/"
            className="bg-gray-200 hover:bg-gray-300 text-gray-800 py-2 px-4 rounded"
          >
            キャンセル
          </Link>
          <button
            type="submit"
            disabled={isSubmitting}
            className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded disabled:opacity-50"
          >
            {isSubmitting ? "保存中..." : "保存"}
          </button>
        </div>
      </form>
    </div>
  );
}

◼︎ 記事作成APIの実装
packages/frontend/src/services/articles/index.tsファイルに、記事作成APIのクライアントを追加します。

export async function createArticle(
  data: CreateArticle
): Promise<
  | { success: boolean; message: string }
  | { success: boolean; data: Article }
> {
  const response = await apiClient.api.articles.$post({
    json: data,
  });
  return await response.json();
}

http://localhost:3000/articles/new にアクセスして、新しい記事を作成できることを確認してください。
Image from Gyazo

※記事編集ページ作成方法は割愛します。
こちらのリポジトリからご確認ください。
https://github.com/ippei-shimizu/hono-nextjs-monorepo-zenn

8. デプロイ

最後に、アプリケーションのデプロイを行います。

👨‍💻 Supabaseへのデプロイ

Supabaseの本番環境を構築します。

  1. Supabaseにサインアップまたはログインします。
  2. dashboardページの「New Project」→「Choose organization」を選択し、Crate a new projectの「Project name」と「Database Password」を入力し、「Region」を「Tokyo」に設定して、「Create new project」をクリックします。
    (ここで指定したDatabase Passwordは、データベースURLに使用するため控えておいてください。)
  3. packages/apiディレクトリで、pnpm run db:push:prodを実行して、Supabaseのデータベースにマイグレーションを適用します。
$ pnpm run db:push:prod

これで、Supabaseのプロジェクトが作成されます。

👨‍💻 Cloudflare Workersへのデプロイ

packages/apiのアプリケーションをCloudflare Workersにデプロイします。

  1. Cloudflareにサインアップまたはログインします。
  2. packages/apiディレクトリで以下のコマンドを実行して、Cloudflare Workersのプロジェクトをデプロイします。
$ pnpm run deploy
  1. Cloudflare Workersのダッシュボードに移動し、「Workers & Pages」をクリックして、プロジェクトが作成されていることを確認します。
  2. プロジェクトをクリックして、「設定」→「変数とシークレット」に、タイプ=シークレットで以下の環境変数を追加します。
    • DATABASE_URL : SupabaseのデータベースURL
      • SupabaseのデータベースURLは、Supabaseのダッシュボードの「Database」→「Connect」→「Direct connection」から確認できます。
    • APP_FRONTEND_URL : フロントエンドアプリケーションのURL(例: https://your-frontend-url.vercel.app
      • こちらは、Vercelにデプロイした後に設定してください。
    • NODE_ENV : production

👨‍💻 Vercelへのデプロイ

packages/frontendのアプリケーションをVercelにデプロイします。

  1. Vercelにサインアップまたはログインします。
  2. ダッシュボードの「Add New」→「Project」をクリックします。
  3. 「Import Git Repository」からデプロイ対象となるリポジトリを選択します。
  4. 「Root Directory」をpackages/frontendに設定します。
  5. 環境変数を設定します。Vercelのダッシュボードの「Setting」→「Environment Variables」に以下の環境変数を追加します。
    • NEXT_PUBLIC_API_URL : https://<your-cloudflare-workers-url>/api
      • Cloudflare WorkersのURLは、Cloudflare Workersのダッシュボードから先ほどデプロイしたプロジェクトを選択し、「設定」→「ドメインとルート」→「workers.dev」の値になります。
  6. 「Deploy」をクリックしてデプロイします。
  • Vercel Dashboardの「Setting」→「Build and Deployment」→「Framework Settings」の「Install Command」にnpx pnpm@10.8 install --no-frozen-lockfileを設定してみてください。
    (最新バージョンのpnpmを使用してみてください)

9. 動作確認

デプロイが完了したら、フロントエンドアプリケーションのURLにアクセスして挙動確認をしてみてください。

まとめ

本記事では、モノレポ構成を活用してNext.jsとHonoを組み合わせたアプリケーション開発方法を解説しました。TypeScriptによる型安全性と、pnpm/Turborepoによる効率的なパッケージ管理、Supabase/Drizzleによるデータベース連携、そしてCloudflare Workersを活用したサーバーレス構成を実現する方法を紹介しました。これらの構成では、一旦無料で本番環境にデプロイすることができるため、個人開発などで試してみるのも良いかもしれません。

参考文献

https://zenn.dev/yasse/articles/2650d580ae8392
https://zenn.dev/chot/articles/e109287414eb8c

GitHubで編集を提案

Discussion