この記事の登場人物
🧑💻 …社内アプリに「使い方ガイド」を静的HTMLで埋め込んだ先輩
🔰 …オンボーディング資料を書いても誰にも読まれず悩んでる後輩
🔰「先輩、新メンバー向けに操作マニュアル書いたんですけど、誰も読んでくれないんですよ」
🧑💻「どこに置いた?」
🔰「Notionです」
🧑💻「じゃあ読まれないね」
🔰「即答やめてください」
社内向けの業務アプリを運用していると、永遠にこれが来ます。
「これ、どの画面から登録するんですか?」
「この"ステータス"ってどういう意味ですか?」
新しい人が入るたびに、同じ説明を口頭で繰り返す。さすがにマズいと思ってマニュアルを書く。でも読まれない。そしてまた同じ質問が来る。この無限ループ、心当たりありませんか?
結論から言います。ドキュメントは「書く」より「使われる場所に置く」ほうが100倍効きます。 具体的には、アプリのサイドバーに「使い方」タブを足して、アプリの中でガイドが開くようにしました。しかも中身は React でも MDX でもなく、素の静的HTML です。
「なんで静的HTML?」というのがこの記事の本題です。
この記事でわかること
- オンボーディング資料を「アプリ内」に常設する効果と、その最小実装
- なぜ React/MDX ではなく「静的HTML + iframe」を選んだのか(比較あり)
- 認証を追加コードゼロでガイドにかける方法
- iframe の中で本体とデザインを揃える小技(lucideアイコンの SVG スプライト化)
- スマホ対応を属性ひとつで済ませる CSS
- ガイドの更新を AI(Claude Code)に丸投げする運用
まず、「どこに置くか」問題
🧑💻「そもそも、なんでNotionだと読まれないか分かる?」
🔰「…検索しないと出てこないから?」
🧑💻「それもあるけど、もっと単純。ユーザーは作業中、アプリの画面を見てる。詰まったときに『別タブ開いてNotion探して該当ページ探す』って手間、人間はやらないんだよ」
🔰「たしかに自分もやらないです」
🧑💻「でしょ。だからガイドはアプリの中に置く。詰まったその場で、サイドバーのタブを押したら開く。これだけで利用率が段違いになる」
イメージはこうです。普段のアプリ画面の、サイドバーに「使い方」を足すだけ。
┌──────────────┬─────────────────────────────────┐
│ 📊 ダッシュボード │ │
│ 📅 カレンダー │ (いつもの業務画面) │
│ 🔔 お知らせ │ │
│ ───────── │ │
│ ❓ 使い方 ◀────┼── これを押すと… │
│ ⚙️ 設定 │ │
└──────────────┴─────────────────────────────────┘
↓ クリック
┌──────────────┬─────────────────────────────────┐
│ (ガイドの目次) │ ▓▓▓▓▓░░░░░░ ← 読書進捗バー │
│ ・はじめに │ │
│ ・基本の3ステップ │ # このアプリで管理するもの │
│ ・用語集 ◀──────┼── アプリの中にガイドが開く │
│ ・FAQ │ (中身は静的HTML) │
└──────────────┴─────────────────────────────────┘
(↑ このレイアウトを実際に組んだのが、記事冒頭のスクリーンショットです。サイドバーの「使い方」を押すと、右側にガイドが開きます)
🔰「これ、どうやって作るんですか?普通にページ作る感じ?」
🧑💻「そこが今日の本題。あえて普通に作らなかったんだよね」
なぜ React でも MDX でもなく「静的HTML + iframe」なのか
ガイドをアプリ内に常設する、と決めたあと、実装方法は3つ候補がありました。
| 方法 | メリット | デメリット |
|---|---|---|
| A. 専用Reactページ | 本体コンポーネントを再利用できる | 更新のたびに本体をビルド/デプロイ。長文をJSXで書くのが地獄 |
| B. MDX | Markdownで書ける | ビルド・型・依存に巻き込まれる。図やインタラクティブ要素を入れると結局Reactを書く羽目に |
| C. 静的HTML + iframe | 本体と完全に疎結合。1ファイル差し替えで更新完結 | iframeの制約(後述)。Reactコンポーネントは再利用不可 |
選んだのは C です。
🔰「えっ、Reactアプリなのにわざわざ素のHTML?退化してません?」
🧑💻「気持ちはわかる。でも理由が3つあって、全部『更新のしやすさ』に効くんだ」
-
本体のビルドに巻き込まれない。静的HTMLは
public/に置くだけ。ガイドを直しても本体の型チェックもビルドもデプロイも要らない。 -
iframe で完全に隔離できる。ガイド側で
body { ... }を書こうが、本体のTailwindやグローバルCSSは一切干渉しない。逆も然り。1,600行のドキュメントを本体と同居させると事故るが、iframeなら安全。 - 1ファイルで完結するから、更新を丸ごとAIに任せられる(後述)。
🧑💻「要するに、ガイドって機能追加のたびに直すでしょ。AだとJSX書き換えてビルド、Bだと毎回MDXパイプライン。Cなら
index.htmlを差し替えるだけ。この差がデカい」
🔰「なるほど、運用が軽いってことか」
「静的HTMLは古い」わけじゃない
SPA全盛だと忘れがちですが、「ビルド不要・依存ゼロ・どこでも開ける」は静的HTMLの強力な武器です。認証下の社内ツールで、更新頻度が高く、非エンジニアにも見せる――この条件がそろうと、むしろ最適解になります。
全体像
リクエストの流れはこうなっています。
public/guide/ の中に index.html / style.css / script.js / icons.svg が入っていて、これらは外部CDNを一切参照しない完全な自己完結(self-contained)構成です。では中身を見ていきます。
深掘り①:iframe で埋め込む(と、地味にハマる二重スクロール)
ページ本体は拍子抜けするほど短いです。
// app/(dashboard)/guide/page.tsx
'use client';
export default function GuidePage() {
return (
<iframe
src="/guide/index.html"
title="使い方ガイド"
className="block h-full w-full border-0 bg-background"
/>
);
}
public/guide/index.html は静的アセットなので /guide/index.html でそのまま配信されます。これをiframeで読むだけ。
🔰「あ、ほんとに短い。これで終わり?」
🧑💻「……と思うじゃん。これだけだと画面の下に白い帯が出て、スクロールバーが二重になる」
🔰「うわ、ダサいやつだ」
原因は、ダッシュボードの main に余白(p-4 md:p-6)が付いていること。その内側にフル高さのiframeを置くと、親のスクロールとiframe内のスクロールが二重になります。これをレイアウト側で負マージンを使って打ち消します。
// app/(dashboard)/guide/layout.tsx
export default function GuideLayout({ children }: { children: React.ReactNode }) {
return (
// 親 main の padding(p-4 md:p-6) を負マージンで相殺してフルブリードに、
// overflow-hidden で親側スクロールを切る → iframe内スクロールに一本化
<div className="h-full -m-4 md:-m-6 overflow-hidden bg-background">
{children}
</div>
);
}
-m-4 md:-m-6 で親の余白を相殺し、overflow-hidden で親のスクロールを殺す。スクロールはiframe内に一本化されて、白帯も二重バーも消えました。
iframeの下に白い隙間が出るのは「あるある」
iframeは置換要素なので、display: inline(デフォルト)だと行ボックスの下に数pxの隙間ができます。block を当てるだけで消えるので、className に block を忘れずに。
深掘り②:認証は「除外リストに入れないだけ」
社内アプリのガイドは、ログインした人だけが見られればいい。ここで嬉しい発見がありました。
🔰「でも
public/に置いたら、URL直打ちで誰でも見れちゃいません?社外の人とか」
🧑💻「いい質問。普通はそう。でもうちは認証ミドルウェアの『除外リスト』にガイドを入れてないから、自動でログイン必須になってる」
🔰「除外リスト…?」
このアプリは Next.js のミドルウェア(プロキシ)でCookieベースの認証チェックをしています。
// proxy.ts(認証プロキシ)
import { NextRequest, NextResponse } from 'next/server';
export function proxy(req: NextRequest) {
const session = req.cookies.get('auth_session')?.value;
// セッションCookieが無ければログインへ飛ばす
if (!session) {
return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
}
// マッチャー: ここに列挙した"以外"の全パスに認証をかける
export const config = {
matcher: ['/((?!login|callback|api|auth|health|_next|images|favicon.ico).*)'],
};
ポイントは matcher の否定先読み (?!...)。ここに並んでいるのが認証を免除するパスです。login や api や _next(Next.jsの静的アセット)は免除。でも guide はこのリストに入っていない。
つまり、何も書き足さなくてもガイドは認証必須になります。未ログインで /guide/index.html を直接叩いても /login に飛ばされる。
🧑💻「『公開したくない静的ファイルは、除外リストに入れない』。これ覚えとくと便利だよ」
🔰「ミドルウェアって静的ファイルにも効くんですね」
public/ のファイルは「無防備に公開される」と思われがちですが、Next.jsのミドルウェアは静的アセットへのリクエストにも介入できます(_next をmatcherで明示除外しているのがその証拠)。認証下に置きたい静的ファイルは、免除リストから外しておくだけでOK。
深掘り③:iframeの中はReactが使えない → アイコン問題
iframeで隔離した代償が、ここで効いてきます。
🧑💻「iframeの中って、本体アプリのReactの世界の外なんだよね。だから本体で使ってる
lucide-reactのアイコンコンポーネントが使えない」
🔰「じゃあ絵文字でいいじゃないですか」
🧑💻「それやると『本体と見た目が違う別物』感が出る。サイドバーのアイコンが本体と揃ってないと、急に安っぽく見えるんだよ」
🔰「こだわりますね…」
そこで、lucide-react のアイコン定義からSVGスプライトを生成するスクリプトを書きました。lucideの各アイコンは __iconNode(SVG要素の配列)をエクスポートしているので、それを <symbol> にコンパイルします。
// scripts/build-guide-icons.mjs(要点)
import { writeFileSync } from 'node:fs';
const ICONS = [
{ id: 'layout-dashboard', module: 'layout-dashboard' },
{ id: 'calendar', module: 'calendar' },
{ id: 'bell', module: 'bell' },
{ id: 'settings', module: 'settings' },
// ↓ alias re-export の罠(後述)
{ id: 'help-circle', module: 'circle-question-mark' },
];
const elements = await Promise.all(
ICONS.map(async ({ id, module }) => {
const mod = await import(`lucide-react/dist/esm/icons/${module}.js`);
const nodes = mod.__iconNode; // 例: [['path', {d:'...'}], ['circle', {...}]]
if (!Array.isArray(nodes)) {
throw new Error(`${module} は __iconNode を持たない(alias の可能性)`);
}
return { name: id, nodes };
})
);
// __iconNode 配列を <symbol> 群にコンパイル
const symbols = elements.map(({ name, nodes }) => {
const inner = nodes.map(([tag, attrs]) => {
const attrStr = Object.entries(attrs)
.filter(([k]) => k !== 'key')
.map(([k, v]) => `${k}="${String(v).replace(/"/g, '"')}"`)
.join(' ');
return `<${tag} ${attrStr}/>`;
}).join('');
return `<symbol id="${name}" viewBox="0 0 24 24" fill="none" stroke="currentColor" `
+ `stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</symbol>`;
}).join('\n');
writeFileSync('public/guide/icons.svg',
`<svg xmlns="http://www.w3.org/2000/svg" style="display:none">\n${symbols}\n</svg>\n`);
生成された icons.svg をHTMLの先頭で読み込み、各アイコンは <use> で参照します。
<svg class="icon"><use href="icons.svg#layout-dashboard"/></svg>
stroke="currentColor" にしてあるので、CSSの color だけで色が変わります。本体のブランドカラーをCSS変数で渡せば、アイコンの色も自動で揃う。Reactを一切使わず、本体と同一の見た目を再現できました。
🔰「
__iconNodeって何ですか?普通にアイコンのSVG取れないんですか」
🧑💻「lucideは内部的に『SVGの中身を配列で』持ってるんだよ。[['path', {d:...}], ...]みたいに。それを文字列のSVGに組み立て直してる」
lucideの「alias re-export」という罠
lucide-reactの一部アイコンは、実体ではなく別名の再エクスポート専用ファイルになっています。例えば help-circle.js の中身は export { default } from './circle-question-mark.js' のような形で、__iconNode を持ちません。気づかず import すると undefined になって落ちます。
対策:上のスクリプトのように「実体(canonical)のモジュール名」を明示し、__iconNode が配列でなければ即エラーにして気づけるようにしておくこと。
深掘り④:スマホ対応を「属性ひとつ」で
ガイドには表が多い(ステータス一覧、用語の対応表など)。PCはいいけど、スマホだと横スクロールが出て読みにくい。
🔰「テーブルのスマホ対応、毎回つらいんですよね…」
🧑💻「JSなし・属性ひとつでカード化できるよ。これ地味に便利」
仕組みはこうです。カード化したいテーブルに data-mobile-cards を付け、各セルに data-label(=列名)を持たせる。
<table data-mobile-cards="auto">
<thead>
<tr><th>ステータス</th><th>意味</th></tr>
</thead>
<tbody>
<tr>
<td data-label="ステータス">下書き</td>
<td data-label="意味">まだ公開していない準備段階</td>
</tr>
<tr>
<td data-label="ステータス">公開中</td>
<td data-label="意味">ユーザーに見えている状態</td>
</tr>
</tbody>
</table>
あとはCSSが、スマホ幅のときだけテーブルを縦積みカードに変換します。
@media (max-width: 768px) {
table[data-mobile-cards="auto"] thead { display: none; } /* ヘッダ行は隠す */
table[data-mobile-cards="auto"] tr {
display: block;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
}
table[data-mobile-cards="auto"] td { display: block; }
/* 各セルの頭に data-label を"見出し"として差し込む */
table[data-mobile-cards="auto"] td::before {
content: attr(data-label);
display: block;
font-weight: 600;
font-size: 0.75rem;
color: var(--muted);
}
}
見た目の変化はこんなイメージです。
【PC】通常のテーブル 【スマホ】data-mobile-cardsでカード化
┌────────┬────────────┐ ┌────────────────────┐
│ステータス│ 意味 │ │ ステータス │
├────────┼────────────┤ │ 下書き │
│下書き │準備段階 │ → │ 意味 │
│公開中 │公開状態 │ │ 準備段階 │
└────────┴────────────┘ ├────────────────────┤
│ ステータス │
│ 公開中 │
│ 意味 / 公開状態 │
└────────────────────┘
肝は td::before { content: attr(data-label); }。CSSの attr() でHTML属性の値を擬似要素として描画し、隠したヘッダ行の代わりにしています。レスポンシブテーブルの定番ですが、data-mobile-cards="auto" という「スイッチ属性」を噛ませることで、カード化したい表にだけ後付けで適用できるのがミソです。
🔰「
attr()ってcontentの中でしか使えないんでしたっけ」
🧑💻「今のところ実用的に使えるのはほぼcontent内だね。でもこのパターンならそれで十分」
深掘り⑤:長文を「読ませる」ための地味な工夫
ガイドのJS(script.js)は数百行ありますが、やっているのは「長文ドキュメントを読みやすくする」UXの積み重ねです。要点だけ:
-
読書進捗バー:
scrollY / scrollHeightで上部のバーを伸ばす。「あとどれくらい?」を可視化 -
目次の現在地ハイライト:
section[id]の位置を見て、対応する目次リンクを.activeに -
スクロールでフェードイン:
IntersectionObserverで監視(スクロールイベント連打を避ける) -
FAQ検索 / アコーディオン:
inputイベントでtextContent.includes(q)の素朴な絞り込み
そして、地味だけど一番こだわったのがアクセシビリティ。
🧑💻「スマホでサイドバーを閉じてるとき、中のリンクって画面外にあるよね。あれ
display:noneにしてないと、Tabキーで見えないリンクにフォーカスが入るんだよ」
🔰「あー、フォーカスがどっか消えるやつ。たまにありますね」
🧑💻「それ防ぐのにinertを使う」
// サイドバーが画面外(閉)のとき、中の要素をフォーカス不能にする
if (isMobile && !isOpen) {
sidebar.setAttribute('aria-hidden', 'true');
sidebar.setAttribute('inert', ''); // ← Tab・クリックの対象から完全に外す
} else {
sidebar.removeAttribute('aria-hidden');
sidebar.removeAttribute('inert');
}
aria-hidden だけだと、画面外に隠れたサイドバーのリンクにキーボードのTabでフォーカスが入ってしまう(見えないのにフォーカスが消える、最悪のUX)。inert 属性を併用すると、その要素以下をフォーカス・クリックの対象から完全に外せます。閉じたアコーディオンの回答にも同じく inert を当てています。
inert は比較的新しい属性ですが、モダンブラウザでは広くサポートされています。「画面にあるけど今は操作対象にしたくない要素」に当てるのが定石。モーダルの背後、閉じたドロワー、非表示タブの中身などに有効です。
深掘り⑥:ガイドの更新を AI に丸投げする
ここが「あえて静的HTML」の真価です。ガイドが 1ファイル(+CSS/JS/SVG)で完結しているので、更新を丸ごとAIに任せられます。
僕は本体に機能を足したら、Claude Code にこう頼みます。
🧑💻(Claudeへ)「
◯◯機能を追加した。このPRの差分を見て、public/guide/index.htmlの該当セクションに新しい操作手順とバッジの意味を追記して。既存のHTML構造・クラス名・トーンに合わせること」
Claudeが該当セクションだけ書き換える。本体のビルドも型チェックも不要(静的アセットなので)。アイコンを増やしたら build-guide-icons.mjs を再実行すればスプライトも更新されます。
🔰「これ、もし本体がReactページだったら?」
🧑💻「JSX全体を読ませて、型崩さないように書き換えて、ビルド通して…って手数が増える。HTML1ファイルなら『この部分だけ直して』で済む。AIに任せる単位として、静的HTMLはめちゃくちゃ相性がいいんだよ」
🔰「ドキュメントが本体と同じテンポで育つわけか」
実際、機能追加が溜まったタイミングで「ガイドを丸ごとリフレッシュ」もしました。カラーの刷新、新機能セクションの追加、スマホ最適化まで、HTMLを書き換えるだけで反映できています。
正直、ここはイマイチ(トレードオフ)
良いことばかり書くとフェアじゃないので、弱点も。
🔰「逆に、困ったことってないんですか?」
🧑💻「あるよ。正直に言う」
-
SPAのルーティングと切れる:iframe内はアプリのRouterの外。ガイド内のスクロール位置や開いたセクションは、ブラウザの「戻る」と連動しない。
/guide#section-5みたいなディープリンク共有も一工夫いる。 - デザイントークンの同期が手動寄り:本体のCSS変数を自動で引き継げるわけではない。色は手で合わせる(アイコンはスプライト生成で半自動化したが、レイアウトの色は手動同期した)。
-
本体のコンポーネントを再利用できない:本体の
<Button>や<Badge>は使えない。だからこそアイコンのSVG化が必要だった。 - 公開ドキュメントには向かない:今回は「認証下の社内ツール」だから成立する構成。SEOやOGPが必要な公開マニュアルなら、素直にMDX + 専用ページのほうがいい。
🧑💻「逆に言うと、認証下・社内向け・更新頻度が高い・非エンジニアにも見せる――この条件がそろうなら、静的HTML + iframeはかなり強い」
🔰「うちの管理アプリ、全部当てはまりますね」
🧑💻「だろ?」
やってみて気づいたこと
予想通りだったこと
- アプリ内に置いたら利用率が上がった。「詰まったその場で開ける」は正義。
- 1ファイル完結なので、更新がとにかく軽い。
予想外だったこと
- 一番効いたのは「AIに更新を任せられる」点だった。最初は単に「軽いから」で静的HTMLにしたが、結果的にAI運用との相性が最高の決め手になった。
- 口頭での質問が減ると、「ガイドのこの説明、分かりにくい」というフィードバックが来るようになった。ドキュメントが改善のループに乗った。
🔰「ドキュメントって書いて終わりだと思ってました」
🧑💻「『置く場所』を変えるだけで、生き物になるんだよ」
まとめ
- オンボーディング資料は 「使われる場所」に置く。アプリの外に置いた瞬間、読まれなくなる。
- アプリ内常設は
<iframe>で静的HTMLを埋め込むだけ。本体と疎結合に保てる。 - 認証は ミドルウェアの除外リストに入れないだけで効く。
- iframe隔離の代償(Reactが使えない)は、lucideアイコンのSVGスプライト化で見た目を揃えて解消。
- スマホ対応は
data-label+ CSSのattr()で属性ひとつ。 - そして何より、1ファイルで完結しているから、更新をAIに丸投げできる。ドキュメントが本体と同じテンポで育つ。
ドキュメントを書くゴールは、書くことじゃなくて 読まれること。だったら、いちばん読まれる場所=アプリの中に置けばいい。そういう話でした。
おわりに
🔰「先輩、うちのアプリにも入れていいですか」
🧑💻「どうぞ。public/guide/index.html置いて、サイドバーにタブ足して、iframeで読むだけ。30分でできるよ」
🔰「Notionのマニュアル、供養します」
🧑💻「成仏させてあげて」
「マニュアルが読まれない」と悩んでいる人、置き場所を疑ってみてください。中身じゃなくて場所の問題かもしれません。
ここまで読んでいただきありがとうございました。同じように社内ツールのオンボーディングで消耗している人の役に立てば嬉しいです。LGTM・コメントもらえると次を書く燃料になります🔥
