🎯

HonoXで始めるフルスタックSSRアプリケーション開発 - Rails的な開発体験をモダンな技術で実現する

に公開

本記事のサマリ

最近、HonoとHonoXを使ったフルスタックアプリケーション開発に挑戦してみました。この記事では、HonoXを使ってちょっとしたブログアプリを作る過程を通じて、Railsのようなモノリス(一枚岩)なSSR(Server-Side Rendering)アプリケーションの構築方法について書いていきます。

ただし、最初にお断りしておきたいのですが、HonoXは現在アルファ版です。それに今回紹介する方法も、標準的な使い方とは少し違う「力技」寄りなアプローチです。実験的な取り組みだということをあらかじめご理解いただければと思います。

記事では、ファイルベースルーティング、Viteミドルウェアの統合、SSRレンダリングなどを書いてまいります。useEffectなどのReact Hooksを使ったクライアント側のインタラクションについては、また今度検証していく予定です。
(性急にCSRとSSRを両方やると、どっちで処理されるコードか分かりづらくなるのでは?という懸念からまずはSSRに絞ってます)

また、今回の実装コードは以下のGitHubリポジトリで公開しています:

https://github.com/toto-inu/lab-202510-nestjs_vs_hono/tree/hono_fullstack/hono_app

HonoXとの出会い - なぜこのフレームワークを選んだのか

モダンなWeb開発では、フロントエンドとバックエンドを分離したアーキテクチャが主流ですよね。でも、時々思うんです。「もっとシンプルに、一つのサーバーで完結させたいな」って。特にプロトタイプを作るときや小規模なアプリを作るとき、Rails的な統合された開発体験が恋しくなったりします。

そんなときに出会ったのがHonoXでした。

https://github.com/honojs/honox

HonoXは、軽量で高速なWebフレームワークHonoをベースにした、フルスタックWebサイトやWeb APIを作るためのメタフレームワークです。

公式ドキュメントによると、HonoXの主な特徴はこんな感じです:

  • ファイルベースルーティング: Next.jsのような大規模アプリケーションが作成可能
  • 高速なSSR: Honoによる超高速レンダリング
  • BYOR(Bring Your Own Renderer): 好きなレンダラーを選択可能
  • Islandsハイドレーション: インタラクティブな部分のみにJavaScriptを配信
  • ミドルウェア: Honoの豊富なミドルウェアが使用可能

というわけで今回は、HonoXを使って全てをSSRで処理する & 一つのサーバーで完結するという、Rails的な開発体験を実現してみようと思います。

実験的アプローチの説明 - Rails的な開発体験を目指して

まず最初に断っておきたいのは、今回のやり方はHonoXの標準的な使い方ではないということです。

HonoXが本来想定している使い方は、こんな感じです:

  1. SSRによる初期ページレンダリング
  2. Islandsコンポーネントを使ったクライアント側のインタラクション(部分的なハイドレーション)
  3. 必要に応じてAPIエンドポイントを別途定義

一方、今回の実験的アプローチでは:

  1. 全てSSRで処理(SPAではなく、従来のサーバーサイドWebアプリケーション)
  2. フォームの送信も従来のHTMLフォームを使用
  3. app/src/modules以下にバックエンドロジック(サービス層)を配置
  4. クライアント側のJavaScriptは最小限(まだIslandsを使用していない)

このアプローチ、確かに「力技」なんですよね。でも、Rails的な開発体験を求める開発者にとっては、一つの可能性を示せるんじゃないかなと思っています。

アーキテクチャの核心 - Viteミドルウェアによる統合戦略

HonoXが一つのポートでフロントエンドとバックエンドを統合できる仕組み、これが実は結構面白いんです。開発環境では @hono/vite-dev-serverというパッケージがViteのミドルウェアとして動いて、リクエストをうまいこと振り分けてくれます。

開発サーバーの起動は vite devコマンド一つで完了します。内部的には、Viteの開発サーバーがメインサーバーとして動いて、その中でHonoアプリケーションがミドルウェアとして実行される仕組みになっています。

プロジェクト構造の理解 - ファイルベースルーティングの威力

実際に作ったプロジェクトの構造はこんな感じです:

.
├── app
│   ├── client.ts
│   ├── routes
│   │   ├── index.tsx
│   │   └── posts
│   │       ├── [id].tsx
│   │       └── create.tsx
│   ├── server.ts
│   └── src
│       ├── index.ts
│       └── modules
│           ├── posts
│           └── users
├── package.json
├── tsconfig.json
└── vite.config.ts

この構造で注目してほしいのは app/routesディレクトリです。HonoXはファイルベースルーティングを採用していて、このディレクトリ内のファイル構成が、そのままURLパスに対応するんですよ。

例えば:

  • app/routes/index.tsx/
  • app/routes/posts/[id].tsx/posts/:id
  • app/routes/posts/create.tsx/posts/create

Next.jsのApp RouterとかNuxt.jsを使ったことがある方なら、この仕組みはピンとくるんじゃないでしょうか。

SSRレンダリングの実装 - createRouteによるコンポーネント定義

HonoXでSSRコンポーネントを作るときは、createRoute関数を使います。投稿一覧ページの実装を見てみましょう:

// app/routes/index.tsx
import { createRoute } from 'honox/factory'
import { PostService } from '../src/modules/posts/index.js'

export default createRoute(async (c) => {
  const postService = PostService.getInstance()
  const posts = await postService.getPosts()

  return c.render(
    <div style={{ padding: '20px', fontFamily: 'system-ui', maxWidth: '800px', margin: '0 auto' }}>
      <h1>Posts</h1>
  
      {/* 投稿作成フォーム */}
      <div style={{ marginBottom: '32px', padding: '20px', border: '2px solid #0066cc', borderRadius: '8px', backgroundColor: '#f8f9fa' }}>
        <h2 style={{ marginTop: 0 }}>Create New Post</h2>
        <form method="post" action="/posts/create" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
          <div>
            <label htmlFor="title" style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
              Title
            </label>
            <input
              type="text"
              id="title"
              name="title"
              required
              style={{ width: '100%', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
            />
          </div>
          <div>
            <label htmlFor="content" style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
              Content
            </label>
            <textarea
              id="content"
              name="content"
              required
              rows={4}
              style={{ width: '100%', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc', resize: 'vertical' }}
            />
          </div>
          <button
            type="submit"
            style={{ padding: '10px 20px', fontSize: '16px', fontWeight: 'bold', backgroundColor: '#0066cc', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
          >
            Create Post
          </button>
        </form>
      </div>

      {/* 投稿一覧 */}
      <h2>All Posts</h2>
      <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
        {posts.map((post) => (
          <div key={post.id} style={{ border: '1px solid #ddd', padding: '16px', borderRadius: '8px' }}>
            <h3 style={{ marginTop: 0 }}>
              <a href={`/posts/${post.id}`} style={{ color: '#0066cc', textDecoration: 'none' }}>
                {post.title}
              </a>
            </h3>
            <p style={{ color: '#666', margin: '8px 0 0 0' }}>
              {post.content}
            </p>
            <small style={{ color: '#999' }}>
              Posted on {new Date(post.createdAt).toLocaleDateString()}
            </small>
          </div>
        ))}
      </div>
    </div>,
    { title: 'Posts - Blog' }
  )
})

このコードで押さえておきたいポイントは:

  1. createRoute関数がHonoのContext(c)を受け取ってる
  2. サービス層PostService)からデータを取得してる
  3. **c.render()**でReactコンポーネントをSSRレンダリングしてる
  4. 従来のHTMLフォームを使ってる(method="post" action="/posts/create"

この実装、まさにRails的な開発体験そのものですよね。サーバーサイドでデータを取得して、HTMLを生成してクライアントに送信する。王道中の王道です。

動的ルーティングとフォーム処理

個別の投稿表示ページでは、動的ルーティングを使っています:

// app/routes/posts/[id].tsx
export default createRoute(async (c) => {
  const id = c.req.param('id')
  const postId = parseInt(id, 10)

  const postService = PostService.getInstance()
  const post = await postService.getPostById(postId)

  if (!post) {
    return c.notFound()
  }

  const formattedDate = post.createdAt.toISOString().split('T')[0]

  return c.render(
    <div style={{ padding: '20px', fontFamily: 'system-ui', maxWidth: '800px', margin: '0 auto' }}>
      <a href="/" style={{ color: '#0066cc', textDecoration: 'none', marginBottom: '20px', display: 'inline-block' }}>
        ← Back to Posts
      </a>
      <article>
        <h1>{post.title}</h1>
        <div style={{ color: '#666', marginBottom: '20px' }}>
          <span>{formattedDate}</span>
        </div>
        <div style={{ lineHeight: '1.6' }}>
          {post.content}
        </div>
      </article>
    </div>
  )
})

フォーム処理も、同じようにシンプルに書けます:

// app/routes/posts/create.tsx
export const POST = createRoute(async (c) => {
  const postService = PostService.getInstance()
  const formData = await c.req.formData()
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  if (title && content) {
    await postService.createPost({ title, content })
  }

  return c.redirect('/')
})

このフォーム処理の実装、HTTPメソッドごとに異なるハンドラーを定義できるのが面白いところです。POSTをエクスポートすれば、同じパスに対するPOSTリクエストを処理できるんですね!

サービス層パターンの活用

私がこのプロジェクトで特に気に入ったのは、サービス層パターンを使えたことです。PostServiceクラスでビジネスロジックを抽象化すると、コントローラー(ルート)の責務をきれいに分離できるんですよね:

// app/src/modules/posts/postService.ts
export class PostService {
  private static instance: PostService
  private posts: Post[] = []

  private constructor() {
    // 初期データの設定
    this.posts = [
      {
        id: 1,
        title: "Welcome to HonoX Blog",
        content: "This is the first post in our HonoX-powered blog!",
        createdAt: new Date('2024-01-01')
      }
    ]
  }

  static getInstance(): PostService {
    if (!PostService.instance) {
      PostService.instance = new PostService()
    }
    return PostService.instance
  }

  async getPosts(): Promise<Post[]> {
    return [...this.posts].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
  }

  async getPostById(id: number): Promise<Post | undefined> {
    return this.posts.find(post => post.id === id)
  }

  async createPost(data: { title: string; content: string }): Promise<Post> {
    const newPost: Post = {
      id: Math.max(...this.posts.map(p => p.id)) + 1,
      title: data.title,
      content: data.content,
      createdAt: new Date()
    }
    this.posts.push(newPost)
    return newPost
  }
}

シングルトンパターンを使うことで、アプリケーション全体で一つのデータストアを共有できます。実際のアプリケーションなら、ここにデータベース接続とかORMとの統合を組み込むことになりますね。

開発体験の実際 - 一つのコマンドで全てが起動

実際に開発を進めてみて、一番感動したのは npm run dev一つのコマンドで、フロントエンドもバックエンドも同時に起動して、ライブリロードまで効くところでした。

npm run dev

このコマンドを実行すると:

  1. Viteの開発サーバーが起動(ポート3000)
  2. HonoXアプリケーションがミドルウェアとして統合
  3. ファイルベースルーティングが自動生成
  4. TypeScriptのコンパイルとライブリロード
  5. ReactコンポーネントのHMR(Hot Module Replacement)

これらが全部自動で設定されるので、開発者は純粋にアプリケーションの機能開発に集中できるわけです。

力技だからこそ生まれる価値 - そして制約

今回のアプローチ、実は二重の意味で「力技」なんですよね。

第一の力技:HonoX自体のアーキテクチャ

HonoX自体が、Viteのミドルウェアとして動くHonoアプリケーション、正規表現でのリクエスト振り分け、環境によって全く違う実行パス、といった巧妙な実装で成り立っています。

第二の力技:今回の実験的な使い方

HonoXが本来想定している「Islands + SSR」じゃなくて、「全部SSR」という方法を採用したので、さらに独自路線を突き進んでる感じです。

でも、この「力技」だからこそ実現できてる価値もあるんです:

開発者体験の向上:複雑なプロキシ設定とかCORS設定とか考えなくて済みます。一つのサーバーで完結するので、開発環境の構築がめちゃくちゃ簡単なんですよ。

デプロイの簡素化:フロントエンドとバックエンドが統合されてるから、一つのアプリケーションとしてデプロイできます。

学習コストの低減:Rails経験者なら馴染みのある開発フローで、モダンなReact + TypeScript開発を始められます。

一方で、制約もあります:

クライアント側のインタラクション:今は従来のHTMLフォームを使ってますが、useEffectとかのReact Hooksを使った複雑なクライアント側の状態管理については、今後Islandsアーキテクチャを導入して検証していかないとですね。

スケーラビリティ:大規模なアプリケーションだと、フロントエンドとバックエンドを分離したマイクロサービスアーキテクチャの方が向いてるかもしれません。

アルファ版のリスク:HonoXはまだアルファ版なので、破壊的変更が入る可能性があります。本番環境で使うかどうかは、慎重に考えたほうがいいです。

まとめ - 実験的アプローチから見えてきたもの

HonoXを使った実験的な開発を通じて、私はモダンなWeb開発の新しい可能性を感じました。同時に、アルファ版のフレームワークを使う難しさとか、標準的な使い方から外れることのリスクも実感しましたね。

今回の実験でわかったこと:

Rails的な「全部SSR」という開発体験は、HonoXでもそれなりに実現できます。ただし、これは公式が想定してる使い方じゃなくて、あくまで実験的なアプローチなんですよね。

今後の課題:

  • Islandsアーキテクチャの導入useEffectとかのReact Hooksを使ったクライアント側のインタラクションを実現するために、Islandsコンポーネントをちゃんと使ってみようと思います。
  • 本番環境での検証:アルファ版のHonoXを本番環境で使うときの注意点とか、パフォーマンス特性とかを確認していかないとですね。
  • スケーラビリティの検証:もっと大規模なアプリケーションでも、この方式が使えるかどうか試してみたいです。

こんな場面で試してみる価値があるかも:

プロトタイプ開発や学習目的で、アイデアをさっと形にしたいとき。ただし、本番環境で使うかどうかは慎重に考えてくださいね。

小規模な個人プロジェクトで、インフラの複雑さを避けたいとき。ただ、アルファ版だってことは頭に入れておいてください。

Rails的な開発体験を求めつつ、モダンなフロントエンド技術を学びたいとき。これが標準的な使い方じゃないってことは、ちゃんと認識しておいてくださいね。

HonoXは挑戦的で、フロントエンドとバックエンドの境界を再定義しようとするフレームワークだと思っています。ただ、その挑戦はまだ始まったばかりで、私たち開発者も一緒に試行錯誤していかなくてはですね!

皆さんも、学習目的とか実験的なプロジェクトで、HonoXを試してみてはどうでしょうか。ただ、アルファ版であることと、今回の方法が標準的じゃないことは理解した上で、慎重に取り組んでくださいね。きっと新しい発見があると思います。

早くStableになってくれると嬉しいです!🔥


https://hono.dev/docs/getting-started/basic

株式会社StellarCreate | Tech blog📚

Discussion