📛

情シス・セキュリティ担当者向けWebプログラミングハンズオン「Hono + JSX + SQLiteで作る掲示板」

に公開

この記事は MICIN Advent Calendar 2025 の 1 日目の記事です。
https://adventar.org/calendars/11601

はじめに

MICIN 情報セキュリティチームの manimoto です。

情報セキュリティチームでは、セキュリティ関連の業務に加え、いわゆる情シス・コーポレートエンジニア的な立場からもさまざまな開発を行っています。たとえば、

  • Google スプレッドシートと Google Apps Script を用いた業務自動化・効率化
  • AWS Lambda を利用した Slack App の開発

といった軽量な開発を日常的に行っています。

一方で、いわゆる「Web アプリケーション開発」にまで踏み込む機会はあまりありません。
正直、がっつりとした Web アプリを作るのは工数的にもハードルが高いところです。

とはいえ、Web アプリの基本概念(CRUD, REST など)を理解しておくことは、GAS や AWS Lambda の開発にも大いに役立つと感じています。
ただ、これらをサッと学べる教材がなかなか見つからず……

  • バックエンド中心にしたいので CSS やフロントエンド JavaScript は書かない
  • Go API + React はやや重く、SSR 構成にしたい
  • Next.js や Remix は概念が多く、初学にはやや複雑
  • Express.js は良い線いっているが、EJS を書くのは避けたい
  • PHP は……うーん……

そんな中「Hono + JSX + SQLite」の組み合わせがコンパクトで、教材としてちょうど良さそうでした。
今回この構成でハンズオン教材をまとめてみることにしました。

1. CRUD の概要

CRUD とは、データを扱う 4 つの基本操作の頭文字を取った言葉です。

操作 意味
Create データを作成する
Read データを読み取る
Update データを更新する
Delete データを削除する

どんなアプリでも、基本的にはこの CRUD 操作の組み合わせで成り立っています。
たとえば「掲示板アプリ」を例にすると、

操作 具体的な画面・動作
Create 「新規投稿」フォームでタイトルと本文を入力して、投稿する
Read 投稿一覧ページや投稿詳細ページで内容を表示する
Update 投稿を編集フォームで書き換えて再保存する
Delete 投稿を削除ボタンで削除する

これらの操作をすべて揃えると、「データを一通り扱えるアプリ」になります。
つまり、CRUD = アプリ開発の最小単位 と言えます。

2. REST の概要

REST(REpresentational State Transfer) は

Web のルール(HTTP・URL・HTML)をうまく使って、シンプルにリソースをやり取りしよう

という Web システム設計のアーキテクチャスタイルです。

REST を理解するうえで重要なのは、以下 3 つのキーワードです。

  1. リソース(Resource):操作対象となるもの。データやオブジェクト
    • 投稿 (message), コメント (comment), ユーザー (user)
  2. URI(識別子):リソースの「住所」
    • /messages, /messages/1
  3. HTTP メソッド:リソースに対して「何をしたいか」
    • GET, POST, PUT, DELETE

先ほどの CRUD と REST を合わせると以下のように整理できます。

操作 意味 REST(HTTP メソッド + URI) 具体例(掲示板)
Create 作成する POST /messages 新しい投稿を追加する
Read 読み取る GET /messages, GET /messages/1 投稿一覧を表示する/投稿内容を表示する
Update 更新する PUT /messages/1 投稿を編集・保存する
Delete 削除する DELETE /messages/1 投稿を削除する

3. プロジェクト準備

3.1. 全体像

さて、ここから実際に掲示板を作っていきましょう。
完成形の全体像は以下図のようになります。

3.2. プロジェクト作成 & 必要パッケージのインストール

それではターミナルを起動して、プロジェクトを作成していきましょう。

(本記事では「Node.js のインストール手順」は割愛しています。知りたい方は以下の記事をご参照ください)
https://zenn.dev/manimoto/scraps/1395cb060f8ca4

ターミナル
mkdir hono-board
cd hono-board
npm init -y
npm install hono @hono/node-server better-sqlite3
npm install -D typescript tsx @types/node

3.3. package.json の設定

hono-board フォルダの配下にある package.json に以下をコピペしてください。

package.json
{
  "name": "hono-board",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "tsx watch src/server.tsx"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "@hono/node-server": "^1.19.6",
    "better-sqlite3": "^12.4.1",
    "hono": "^4.10.6"
  },
  "devDependencies": {
    "@types/node": "^24.10.1",
    "tsx": "^4.20.6",
    "typescript": "^5.9.3"
  }
}

3.4. tsconfig.json の作成(JSX 設定)

Hono の JSX を使うために tsconfig.json ファイルを hono-board フォルダの配下に作成し、以下をコピペしてください。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx",

    "outDir": "dist",
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"]
}

3.5. SQLite 接続 & テーブル作成(src/db.ts)

まずデータベースを用意し、掲示板の投稿を保存する messages テーブルを作成します。
hono-board フォルダの配下に、 src フォルダを作成します。
src 配下に db.ts ファイルを作成し、以下をコピペしてください。

src/db.ts
import Database from "better-sqlite3";

// データベースを用意
const db = new Database("app.db");

// messages テーブルを作成
db.exec(`
  CREATE TABLE IF NOT EXISTS messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  );
`);

export default db;

3.6. 掲示板本体(src/server.tsx)

src 配下に server.tsx ファイルを作成し、以下をコピペしてください。

server.tsx
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import db from "./db";

// アプリ本体
const app = new Hono();

// トップページ
app.get("/", (c) => {
  return c.text("Hello, world!");
});

// サーバ起動
const port = 3000;
console.log(`Server is running on http://localhost:${port}`);

serve(
  {
    fetch: app.fetch,
    port,
  },
  (info) => {
    console.log(`Hono listening on http://localhost:${info.port}`);
  }
);

3.7. 動作確認

ここまで作成したら動くか確認しましょう。まず、ターミナルを開いて、以下を実行します。

ターミナル
npm run dev

次にブラウザで http://localhost:3000/ にアクセスします。
ブラウザ上に「Hello, world!」と表示されれば成功です。

これで Hono + SQLite プロジェクトの準備は整いました!

4. HTML 版 「Hello, world!」

さきほどまで単にブラウザ上に「Hello, world!」という文字が出るだけでした。
これでは Web っぽくないですね。
そこで HTML で書いた Web ページが表示されるようにしましょう。

src/server.tsx の トップページ app.get("/", (c) => { の部分を以下のように書き換えてください。

src/server.tsx
// トップページ
app.get("/", (c) => {
  const html = `
    <!doctype html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title>トップ</title>
      </head>
      <body>
        <h1>Hello, world! (HTML版)</h1>
        <ul>
          <li><a href="/messages">投稿一覧</a></li>
        </ul>
      </body>
    </html>
  `;
  return c.html(html);
});

// サーバ起動

ブラウザで http://localhost:3000/ にアクセスし、Web ページが表示されれば OK です。

5. JSX 版「Hello World!」

HTML を書いたのですが、プログラム上に直接書くのは手間がかかります。
その上、これからデータベースから掲示板の投稿を取得して一覧を描画したりするので、ただの HTML から一工夫する必要があります。

その一工夫が JSX です。

JSX とは動的な web ページを記述する書き方の一つで、条件分岐や、変数の埋め込みに対応しています。

少しびっくりする見た目なのですが、HTML と似ているので最初はあまり深く考えず、まずはコードを写経するで大丈夫です。

5.1. レイアウトの作成(src/views/Layout.tsx)

まず全ページ共通のレイアウトを作成します。

src 配下に views フォルダを作成してください。
次に views フォルダ配下に Layout.tsx ファイルを作成し、以下を写経してください。

src/views/Layout.tsx
import type { FC } from "hono/jsx";

type Props = {
  title?: string;
  children: any;
};

export const Layout: FC<Props> = ({ title, children }) => (
  <html>
    <head>
      <meta charSet="utf-8" />
      <title>{title ?? "掲示板アプリ"}</title>
    </head>
    <body>
      <header>
        <h1>掲示板アプリ(Hono + JSX)</h1>
        <nav>
          <a href="/">トップ</a> | <a href="/messages">投稿一覧</a> | <a href="/messages/new">新規投稿</a>
        </nav>
        <hr />
      </header>

      {children}

      <hr />
    </body>
  </html>
);

コードと HTML が入り混じってる……、と最初はギョとするかもしれません。
ただ export const Layout:〜 以下はほぼ HTML なのでそこは安心してください。

JSX の大きな特徴として {変数名} で変数の値を埋め込める点があげられます。

<title>{title ?? '掲示板アプリ'}</title>

のコードは、

  • ページに変数 title が設定されていればそれをセットし、
  • title が設定されていなければ「掲示板アプリ」をセットする

ようになっています。

実際に次で見ていきましょう。

5.2. トップページの作成(src/views/Home.tsx)

次にこのレイアウトファイルを用いて、トップページを作成します。
views フォルダ配下に Home.tsx ファイルを作成し、以下を写経してください。

src/views/Home.tsx
import type { FC } from "hono/jsx";
import { Layout } from "./Layout";

export const Home: FC = () => (
  <Layout title="トップ">
    <h2>Hello, world!</h2>
    <ul>
      <li>
        <a href="/messages">投稿一覧へ</a>
      </li>
      <li>
        <a href="/messages/new">新規投稿</a>
      </li>
    </ul>
  </Layout>
);

また、 src/server.tsx を以下のように追記・書き換えてください。

src/server.tsx
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import db from "./db";
import { Home } from "./views/Home"; // ←📝 追加

const app = new Hono();

// トップページ
app.get("/", (c) => {
  return c.html(<Home />); // ←📝 書き換え
});

// サーバ起動

ブラウザで http://localhost:3000/ にアクセスし、トップページが表示されれば OK です。
トップページの title が「トップ」になっているかと思います。
また、レイアウトの {children} 部分に src/views/Home.tsx に書いた内容が掲載されていることが確認できるかと思います。

このように変数の内容を埋め込めるのが JSX の大きなメリットです。
これからデータベースから取得した投稿をページに載せるのにも大きく活躍します。

6. 一覧(GET /messages)

ここから本格的に掲示板を作っていきます。まずは一覧からです。

6.1. 型定義

投稿(Message)の型定義を作成します。作成しておくと VSCode が補完をしてくれて便利です。
src フォルダ配下に types.ts ファイルを作成し、以下を写経してください。

src/types.ts
export type Message = {
  id: number;
  title: string;
  body: string;
  created_at: string;
};

6.2. メッセージ一覧

views フォルダ配下に messages フォルダを作成します。
次に、messages フォルダ配下に Index.tsx ファイルを作成し、以下を写経してください。

src/views/messages/Index.tsx
import type { FC } from "hono/jsx";
import { Layout } from "../Layout";
import type { Message } from "../../types";

type Props = {
  messages: Message[];
};

export const MessagesIndex: FC<Props> = ({ messages }) => (
  <Layout title="投稿一覧">
    <h2>投稿一覧 (index)</h2>

    <p>
      <a href="/messages/new">新規投稿</a>
    </p>

    <ul>
      {messages.length === 0 ? (
        <li>まだ投稿はありません。</li>
      ) : (
        messages.map((m) => (
          <li key={m.id}>
            <a href={`/messages/${m.id}`}>{m.title}</a>{m.created_at}</li>
        ))
      )}
    </ul>
  </Layout>
);

また、 src/server.tsx を以下のように追記・書き換えてください。(GET /messages の追加)

src/server.tsx
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import db from "./db";
import { Home } from "./views/Home";
import { MessagesIndex } from "./views/messages/Index"; // ←📝 追加
import type { Message } from "./types"; // ←📝 追加

const app = new Hono();

// トップページ
app.get("/", (c) => {
  return c.html(<Home />);
});

// ↓📝 以下追加
// 一覧(GET /messages)
app.get("/messages", (c) => {
  const rows = db.prepare("SELECT * FROM messages ORDER BY created_at DESC").all() as Message[];

  return c.html(<MessagesIndex messages={rows} />);
});

// サーバ起動

ブラウザで http://localhost:3000/messages にアクセスしてみます。

「まだ投稿はありません。」と表示されます。
まだ投稿をしていないので今はこれで OK です。

しかし投稿がないのは寂しいです。
そこで次に新規投稿フォームを作成し、投稿機能を作成しましょう。

7. 新規投稿(GET /messages/new)と 登録(POST /messages)

messages フォルダ配下に New.tsx ファイルを作成し、以下を写経してください。

src/views/messages/New.tsx
import type { FC } from "hono/jsx";
import { Layout } from "../Layout";

export const MessageNew: FC = () => (
  <Layout title="新規投稿">
    <h2>新規投稿 (new)</h2>

    <form action="/messages" method="post">
      <p>
        タイトル:
        <br />
        <input type="text" name="title" required />
      </p>
      <p>
        本文:
        <br />
        <textarea name="body" rows={5} cols={40} required />
      </p>
      <p>
        <button type="submit">投稿する (create)</button>
      </p>
    </form>

    <p>
      <a href="/messages">一覧へ戻る</a>
    </p>
  </Layout>
);

また、 src/server.tsx を以下のように追記・書き換えてください。(GET /messages/new & POST /messages の追加)

src/server.tsx
import { Hono } from "hono";
(〜〜〜 省略 〜〜〜)
import type { Message } from "./types";
import { MessageNew } from "./views/messages/New"; // ←📝 追加

// フォーム body を扱うため、Hono の標準 body parser を使用
import { parseBody } from "hono/utils/body"; // ←📝 追加

const app = new Hono();

(〜〜〜 省略 〜〜〜)

// ↓📝 以下追加
// new
app.get("/messages/new", (c) => {
  return c.html(<MessageNew />);
});

// ↓📝 以下追加
// create
app.post("/messages", async (c) => {
  const body = await parseBody(c.req); // フォームの name/value を取得

  const title = String(body.title ?? "").trim();
  const text = String(body.body ?? "").trim();

  if (!title || !text) {
    return c.text("タイトルと本文は必須です", 400);
  }

  const info = db.prepare("INSERT INTO messages (title, body) VALUES (?, ?)").run(title, text);

  const newId = Number(info.lastInsertRowid);
  return c.redirect(`/messages`);
});

// サーバ起動

ブラウザで http://localhost:3000/ にアクセスし、新規投稿リンクから投稿してみましょう。

投稿すると一覧に表示されます

8. 詳細(GET /messages/1)

一覧には投稿のタイトルしか表示されません。そこで投稿の本文が表示される各投稿ページを作成しましょう。

messages フォルダ配下に Show.tsx ファイルを作成し、以下を写経してください。

src/views/messages/Show.tsx
import type { FC } from "hono/jsx";
import { Layout } from "../Layout";
import type { Message } from "../../types";

type Props = {
  message: Message;
};

export const MessageShow: FC<Props> = ({ message }) => {
  // 改行を<br>にする処理
  const lines = message.body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");

  return (
    <Layout title={message.title}>
      <h2>投稿詳細 (show)</h2>

      <h3>{message.title}</h3>
      <p>
        {lines.map((line, idx) => (
          <span key={idx}>
            {line}
            <br />
          </span>
        ))}
      </p>
      <p>作成日時: {message.created_at}</p>

      <p>
        <a href={`/messages/${message.id}/edit`}>編集</a>{" "}
        <form action={`/messages/${message.id}`} method="post" style={{ display: "inline" }}>
          <input type="hidden" name="_method" value="DELETE" />
          <button type="submit">削除</button>
        </form>
      </p>

      <p>
        <a href="/messages">一覧へ戻る</a>
      </p>
    </Layout>
  );
};

また、 src/server.tsx を以下のように追記・書き換えてください。(GET /messages/:id の追加)

src/server.tsx
import { Hono } from "hono";
(〜〜〜 省略 〜〜〜)
import { MessageNew } from "./views/messages/New";
import { MessageShow } from "./views/messages/Show"; // ←📝 追加

(〜〜〜 省略 〜〜〜)

// create
app.post("/messages", async (c) => {
  const body = await parseBody(c.req); // フォームの name/value を取得
(〜〜〜 省略 〜〜〜)
  return c.redirect(`/messages`);
});

// ↓📝 以下追加
// show
app.get("/messages/:id", (c) => {
  const id = Number.parseInt(c.req.param("id"), 10);
  const message = db.prepare("SELECT * FROM messages WHERE id = ?").get(id) as Message | undefined;

  if (!message) {
    return c.notFound();
  }

  return c.html(<MessageShow message={message} />);
});

// サーバ起動

ブラウザで http://localhost:3000/messages にアクセスし、一覧の投稿名をクリックすると投稿ページが表示されます。

9. 編集(GET /messages/1/edit)と更新(PUT /messages/1), 削除(DELETE /messages/1)

最後に編集画面と更新, 削除を作成します。

messages フォルダ配下に Edit.tsx ファイルを作成し、以下を写経してください。

src/views/messages/Edit.tsx
import type { FC } from "hono/jsx";
import { Layout } from "../Layout";
import type { Message } from "../../types";

type Props = {
  message: Message;
};

export const MessageEdit: FC<Props> = ({ message }) => (
  <Layout title="投稿編集">
    <h2>投稿編集 (edit)</h2>

    <form action={`/messages/${message.id}`} method="post">
      {/* 擬似 PUT メソッド */}
      <input type="hidden" name="_method" value="put" />

      <p>
        タイトル:
        <br />
        <input type="text" name="title" value={message.title} required />
      </p>
      <p>
        本文:
        <br />
        <textarea name="body" rows={5} cols={40} required>
          {message.body}
        </textarea>
      </p>
      <p>
        <button type="submit">更新する (update)</button>
      </p>
    </form>

    <p>
      <a href={`/messages/${message.id}`}>投稿詳細へ戻る</a>
    </p>
  </Layout>
);

また、 src/server.tsx を以下のように追記・書き換えてください。(PUT /messages/:id, DELETE /messages/:id の追加)

src/server.tsx
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { methodOverride } from "hono/method-override"; // ←📝 追加
import db from "./db";
(〜〜〜 省略 〜〜〜)
import { MessageShow } from "./views/messages/Show";
import { MessageEdit } from "./views/messages/Edit"; // ←📝 追加

(〜〜〜 省略 〜〜〜)

// アプリ本体
const app = new Hono();

app.use("/messages/:id", methodOverride({ app })); // ←📝 追加

(〜〜〜 省略 〜〜〜)

// show
app.get("/messages/:id", (c) => {
(〜〜〜 省略 〜〜〜)
});

// ↓📝 以下追加
// edit
app.get("/messages/:id/edit", (c) => {
  const id = Number.parseInt(c.req.param("id"), 10);
  const message = db.prepare("SELECT * FROM messages WHERE id = ?").get(id) as Message | undefined;

  if (!message) {
    return c.notFound();
  }

  return c.html(<MessageEdit message={message} />);
});

// ↓📝 以下追加
// update
app.put("/messages/:id", async (c) => {
  const id = Number.parseInt(c.req.param("id"), 10);

  const body = await parseBody(c.req);
  const title = String(body.title ?? "").trim();
  const text = String(body.body ?? "").trim();

  if (!title || !text) {
    return c.text("タイトルと本文は必須です", 400);
  }

  const existing = db.prepare("SELECT * FROM messages WHERE id = ?").get(id) as Message | undefined;
  if (!existing) {
    return c.notFound();
  }

  db.prepare("UPDATE messages SET title = ?, body = ? WHERE id = ?").run(title, text, id);

  return c.redirect(`/messages/${id}`);
});

// ↓📝 以下追加
// delete
app.delete("/messages/:id", async (c) => {
  const id = Number.parseInt(c.req.param("id"), 10);

  const existing = db.prepare("SELECT * FROM messages WHERE id = ?").get(id) as Message | undefined;

  if (!existing) {
    return c.notFound();
  }

  db.prepare("DELETE FROM messages WHERE id = ?").run(id);

  return c.redirect("/messages");
});

// サーバ起動

各投稿ページから編集・削除できることを確認します。

以上で掲示板完成です。

おわりに

情報セキュリティや情シスの現場では、「本格的な Web アプリ開発」は必ずしも日常業務の中心ではありません。
しかし、CRUD, REST といった基本概念を一度自分の手で実装してみることで、SaaS の裏側や API 設計が見えてきたのではないでしょうか。
また、自動化・効率化スクリプトの設計をより拡張性のあるものにする手本になると思います。

今後の発展としては「投稿にコメントを付ける(message と comments の 1 対多関係)」や、「ログイン機能」、「CSRF 対策」なども盛り込んでいけると良いと考えています。
これらを少しずつ追加していくことで、より実践的なアプリ構成や、セキュリティの基礎にも自然と触れられるでしょう。

もし、情シスや情報セキュリティ担当者、あるいは他の非エンジニア職の方で、
「サッと Web アプリの仕組みを理解したい!」
という方がいましたら、ぜひ本記事の内容を活用してみてください。


MICIN ではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!
https://recruit.micin.jp/

株式会社MICIN

Discussion