
HonoとJSXを使ったMPAの管理画面がどこまで作り込めるか検証してみた
はじめに
何かしらの管理画面を作る際、ReactやVue.jsのようなSPAのフレームワークを使うケースがあると思いますが、「たったこれだけの機能を提供したいだけなのにこんなに大変なの!?」と思ったことはありませんか?
私の場合、以下のような場合に感じることが多かったです
- 企業内のごく一部のユーザーのみが操作する管理画面
- ユーザーのITリテラシーが高い(コードが書ける・読める)
- CRUDなどの単純な機能しかない管理画面
特に、SPAはフロントエンドとバックエンドそれぞれでメンバーのアサイン・仕様すり合わせ(API発火タイミングなど)・フロントエンドとバックエンド間のスキーマの同期など、色々なことに気を使う必要があり実現したい機能に対しての開発コストが嵩む印象があります。
そこで、思い切ってSPA構成をやめて Hono + JSX を使ったMPA(マルチページアプリケーション)の管理画面がどこまで作り込めるかを検証してみました。
簡単なTODOアプリを作成してAPI Gateway v2 + Lambdaでデプロイし、色々な観点で評価した結果をお伝えします。
作成したTODOアプリ、Claude Codeくんのおかげでわりと綺麗なUIになりました
検証した機能と結果
事前準備として、HonoでJSXを使うには、Hono公式ページ通りにセットアップをする必要があります。
1. 認証機能
管理画面は通常、限られたユーザーしか操作できません。そのために認証機能が必要ですがあまり複雑な仕組みは採用したくない場面は多いと思っています。
そのような場面に利用できるミニマムな認証機能について検証しましたが、結果としてBasic認証が一番採用しやすいと感じました。
- 他の手段(Lambda関数URL+IAM認証、Cookieによるログイン認証)と比べて一番簡単に認証を実現できる
- ログイン画面の実装が不要
- HonoにはBasic認証のミドルウェアが標準で用意されている
app.use("*", basicAuth({
username: "admin",
password: "password"
}));
注意点:AWS Lambdaにデプロイする際の制約
以下の構成でデプロイする場合、Basic認証が正常に動作しません:
- API Gateway v1 + Lambda
- Lambda Function URL
これらの環境ではこの問題によりBasic認証のポップアップが表示されないため注意が必要です。
2. フォームの登録とバリデーション
管理画面では様々なマスタデータ(お知らせマスタなど)を登録/更新するケースが非常に多いため、フォーム機能が重要です。こちらも検証してみました。
基本的なフォーム実装
HonoのJSXでは<form>
タグを使った実装になり、ユーザーが入力したデータをHono側で正しく受け取れることを確認しました。
formタグの中身
<form method="post" className="space-y-4">
{/* テキスト */}
<input
type="text"
id="title"
name="title"
value={inputValue?.title.toString() ?? "無題のタスク"}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="タスク名を入力してください"
/>
{/* プルダウン */}
<select
id="priority"
name="priority"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{priorities.map((p, i) => (
<option value={p.id} selected={p.id === inputValue?.priority}>
{p.name}
</option>
))}
</select>
{/* 日付ピッカー */}
<input
type="date"
id="dueDate"
name="dueDate"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{/* テキストエリア */}
<textarea
id="description"
name="description"
value={inputValue?.description.toString()}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="タスクの詳細を入力してください"
/>
</form>
バリデーション機能
Zodを使ったバリデーションも問題なく実装できました
- POSTリクエスト受信時にバリデーションを実行
- バリデーションエラーがある場合は
ZodError
をJSXまで引き回してエラー表示 - 入力していた値を初期表示として保持(
req.body
をJSXまで引き回す)
hono/validator
パッケージや@hono/zod-validator
パッケージを使ったミドルウェア部分でのバリデーションも検討したのですが、エラー時にJSXに渡すためのデータが多いため、ミドルウェア部分ではなくハンドラーの中で処理した方が良いと判断してます。
/** ハンドラー側 */
app.post("/task/create", async (c) => {
const body = await c.req.parseBody();
const parsedResult = TaskFormSchema.safeParse(body);
// バリデーションエラーの場合
if (!parsedResult.success) {
return c.html(<CreateTask
inputValue={body} // ユーザーが入力した値を画面に初期値として入力しておく
error={parsedResult.error} // エラー内容を画面に表示
/>);
}
// 正常な場合
});
/** JSX側 */
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<h3 className="text-sm font-medium text-red-800 mb-2">
入力エラーがあります
</h3>
<ul className="text-sm text-red-600 space-y-1">
{error.issues.map((err: any, index: number) => (
<li key={index}>
• {err.path.join(".")}: {err.message}
</li>
))}
</ul>
</div>
)}
上記の例では、Zodのエラーメッセージが標準の英語のままです。
管理画面を使うユーザーのリテラシー次第では英語のままでも十分ですが、以下のような方法で日本語に変換することでユーザーに優しい画面にすることも可能です。
3. ファイル添付と画像表示
大量のデータを登録するために、画像ファイルやCSVファイルなどを管理画面から登録したいことが多いと思います。
なのでファイルアップロード機能も検証しました。
実装方法
ファイルアップロードは、<form>
タグにenctype="multipart/form-data"
を指定し、<input type="file">
を使用することで実現できます。
<form encType="multipart/form-data">
<input type="file" accept="image/*" />
</form>
サーバー側バリデーション
Zodではファイルのバリデーションも可能です。(zod内部のコードまで見てないのでおそらく推測ですが、ファイルの中身まで見ているわけではないと思われます、ので実運用する際には注意が必要です)
export const TaskSchema = z.object({
image: z
.file()
.mime(["image/jpeg", "image/gif", "image/jpg", "image/webp", "image/png"])
.max(10_000_000), // 10MB
});
画像の表示
ユーザーがアップロードされた画像を画面に表示する場合、base64エンコードしてHTMLに埋め込むか、S3のpresigned URLを使用する方法が簡単です。
前者の場合だと、HTMLのサイズが大幅に増え、base64にエンコードされた分バイナリデータよりサイズが増えていることに注意する必要があります。
/** ハンドラー側 */
// webの[Fileインタフェース](https://developer.mozilla.org/ja/docs/Web/API/File)を満たしている
const imageFile = parsedResult.data.image;
const ab = await imageFile.arrayBuffer();
const buffer = Buffer.from(ab);
return c.html(<CreateTask image={{ file: imageFile, buffer }} />);
/** JSX側 */
<img
src={`data:${image.file.type};base64,${image.buffer.toString("base64")}`}
alt="選択された画像"
className="w-full max-w-xs mx-auto rounded-lg shadow-md border border-gray-200"
/>
UIの改善
標準のinput[type="file"]
のUIはシンプルですが、CSS次第でわりと綺麗なUIを作成できます。以下のサンプルはTailwind CSSを使っています
<input
type="file"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
input単体にスタイル当てるだけでこんなに綺麗になるのがびっくり
4. ファイルダウンロード機能
アップロードとは逆に、大量のデータをCSV形式などでダウンロードさせる機会もあると思います。
この場合はレスポンスヘッダーにContent-Type
とContent-Disposition
を設定した上で、Response
に文字列/binaryやStreamなどのファイルの中身を渡すことで実現可能です。
// 文字列を渡す場合
app.get("/download/csv", (c) => {
const csvStringData = generateCSV();
return new Response(csvStringData, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="data.csv"',
},
});
});
// s3のファイルをStreamとして渡す場合
app.get("/download/csv-from-s3", async (c) => {
const s3Client = new S3Client({
region: process.env.AWS_REGION || "ap-northeast-1",
});
const command = new GetObjectCommand({
Bucket: "hono-survey-csv-data",
Key: "ratings.csv",
});
const response = await s3Client.send(command);
return new Response(response.Body?.transformToWebStream(), {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="data.csv"',
},
});
});
AWS Lambda使用時の注意点
API Gateway v2とLambdaを利用した場合、デフォルトで29秒の統合タイムアウトや最大10MBのペイロードサイズがあるため、大容量のデータをダウンロードする際やネットワークのスループットが低い場合は注意が必要です。
上限緩和申請を行うことで統合タイムアウトを伸ばすことが可能ですが、当然ですが申請に時間がかかる場合があります。
そのような場合は、S3バケットにレスポンスデータを一度配置し、そのpresigned URLをレスポンスする方法がオススメです。
ステータスコード301でレスポンスすることでブラウザがS3バケットからダウンロードを行うので、間にAPI Gateway v2が挟まらないため時間の制限・ペイロードサイズの制限がありませんし、フロントエンドでJavaScriptを書く必要もありません。
const s3Client = new S3Client({
region: process.env.AWS_REGION || "ap-northeast-1",
});
const command = new GetObjectCommand({
Bucket: "hono-survey-csv-data",
Key: "ratings.csv",
});
const presignedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600,
});
return c.redirect(presignedUrl); // ステータスコード301でリダイレクト
5. 複数ページにまたがるフォーム機能
大量の入力項目がある場合、いくつかのステップに分けてユーザーに入力してもらいたい場面があると思います。
この際、入力されたデータを次のステップ、さらにその次のステップへと順々に渡していく必要があります。
これがHono+JSXではどのような実装になるのか検証してみました。
ステップ1 | ステップ2 | ステップ3 |
---|---|---|
![]() |
![]() |
![]() |
一番最初に思いつくのは、バックエンド側でセッションを管理しそのセッションにぶら下がる形で入力データを保持する方法ですが、セッションを管理するためのデータストアや不要なデータの解放タイミングなどを考えだすとコストがかかります。
そこでオススメしたいのがinput type="hidden"
を使ったステートレスな方法です。
各ステップでは、それより前のページの入力データを<form>
タグの中で input type="hidden"
として保持します。こうすることでフロントエンド側の<form>
タグに全ステップの入力データが保持されるのでバックエンド側でデータを管理する手間が省けます。
<input type="hidden" name="title" value={taskData.title} />
<input
type="hidden"
name="description"
value={taskData.description}
/>
<input type="hidden" name="priority" value={taskData.priority} />
<input type="hidden" name="dueDate" value={taskData.dueDate} />
この場合、バックエンド側では前ステップのデータも含んだ全データのバリデーションが都度必要になりますが、そこまで複雑な実装にはなりませんでした。
以下のようにZodでバリデーションを実行し、エラーがある場合は適切なページに戻すことができます。
// 全てのステップでは同じパス(=同じハンドラー)にsubmitしてます
app.post("/task/update/:id", async (c) => {
const body = await c.req.parseBody();
// ステップ1のバリデーション
const parsedResultStep1 = TaskUpdateStep1Schema.safeParse(body);
if (!parsedResultStep1.success) {
// ステップ1のバリデーションエラーがある場合は、ステップ1の画面に戻す
return c.html(
<UpdateTask1 inputValue={body} error={parsedResultStep1.error} />
);
}
// ステップ1の画面からsubmitされた場合、ステップ2のバリデーションをせずステップ2に進む
if (parsedResultStep1.data.step === "1") {
return c.html(<UpdateTask2 taskData={parsedResultStep1.data} />);
}
// ステップ2のバリデーション
const parsedResultStep2 = TaskUpdateStep2Schema.safeParse(body);
if (!parsedResultStep2.success) {
// ステップ2のバリデーションエラーがある場合は、ステップ2の画面に戻す
return c.html(
<UpdateTask2
taskData={parsedResultStep1.data}
inputValue={body}
error={parsedResultStep2.error}
/>
);
}
// ...大体一緒なので省略
console.log(
"更新完了",
parsedResultStep1.data,
parsedResultStep2.data,
parsedResultStep3.data
);
return c.redirect("/task/list");
});
6. 静的アセットの配信
favicon設定やアイコン画像など、静的アセットを管理画面に組み込みたいケースがあります。
Node.jsの環境ではserveStatic
ミドルウェアを利用することで簡単に実装できます。
AWS Lambdaの場合でもこのミドルウェアを使うことができます。
app.use(
"/assets/*",
serveStatic({ root: "." })
);
{/* headタグの中 */}
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
AWS CDKのデプロイに静的アセットを含めるにはどうすれば良いか
AWS CDKの NodejsFunctionコンストラクタを利用している場合は、bundling.commandHooks.afterBundling
でファイルコピー処理を追加することで、簡単にデプロイパッケージに静的アセットを含めることができます。
const fn = new cdk.aws_lambda_nodejs.NodejsFunction(this, "lambda", {
bundling: {
commandHooks: {
afterBundling: (inputDir: string, outputDir: string): string[] => [
`cp -r ${join(inputDir, "..", "assets")} ${outputDir}`,
],
beforeBundling: () => [],
beforeInstall: () => [],
},
},
});
7. UIの実装
この構成で開発する中で大きなウェイトを占めるのが、JSX、つまりUIの実装です。
ハンドラー側のコード行数に比べてJSX側のコード行数はかなり多くなり、おそらく実装時間も長くなるはずです。また、バックエンドエンジニアがJSXを実装する場面もあるはずなので、人によっては結構苦戦するかもしれません。
検証に使ったTODOアプリでは、UIの実装部分はハンドラーの実装と別ファイルに分離し、Claude Codeを使って全て実装してもらいました。
これがめちゃくちゃ良く、画面の仕様を箇条書きでまとめて依頼すると一発で要望通りの実装をしてくれました。
また、スタイリングライブラリにはTailwind CSSをCDNベースで利用することで、CSSの実装を最小限に抑えることができました。
// セットアップこれだけ
<script src="https://cdn.tailwindcss.com"></script>
Claude Codeは比較的Tailwind CSSの知識があるらしく、変なデザインになっていることは検証中一度もありませんでした。
Claude Codeだけに限らず、Coding Agentを使うことでJSXの実装は大幅に省力化でき、場合によっては7割,8割のJSXの実装をスキップできるのではないか、と感じました。
まとめ
HonoのJSXを使った管理画面開発について検証した結果、以下のことが分かりました
- ✅実現できること
- Basic認証による簡単な認証機能
- フォームの実装とZodによるバリデーション
- ファイルアップロードと画像表示
- CSVダウンロード機能
- 複数ページにまたがるフォーム機能
- 静的アセットの配信
- 🚨注意すべきポイント
- AWS環境でのBasic認証の制約
- API Gateway v2 + Lambdaでの制限
- 大容量データ処理時の工夫が必要
あくまで個人の主観になってしまいますが、シンプルな管理画面であれば、Hono+JSXだけで十分実用的なMPAが作成できるのではないかと思います。
特に、型安全にデータをUIに埋め込むことができるので、冒頭に挙げたスキーマの同期に気を使う必要がない点は大きなメリットです。
また、バックエンドのファイルとUIのファイルが物理的に近いため、Claude CodeなどのCoding Agentにとっても読み取りやすいのではないかなと思います。
ただし、この構成の一番大きなデメリットは、UIでReactやVue.jsのようなインタラクティブな実装ができない点です。
ボタンが押されたらリッチなダイアログを表示したり、GoogleMapのような動的な地図データをシームレスにサーバーから取得して画面に表示することが簡単には実現できないと思います。
リリース当初は良かったが後々になって上記のような要件が出てきた際には、ReactなどのSPA構成に移行せざるを得ないケースが出てくるかもしれません。
こうならないように注意して導入の可否を決める必要があります。
なんにせよ、私の中では選択肢が1つ広がったので喜ばしいことだと思ってます。
ちなみに、最近ではHonoとViteを使ったメタフレームワークであるHonoXが登場していますが、今回のようなニーズでどのぐらい効果があるかは未検証です。
現在はアルファ版だと思うので、GAになった際には似たような検証をしてみたいです。
検証に使ったコード