# ともきちのエンジニア成長記 — Articles (Japanese)
---
title: "「AI向け最適化」と呼ぶ前に、技術ブログへllms.txtとWebMCPを実装した理由"
description: "LLMOやAIエージェント向けWebの正解がまだ固まっていない中で、技術ブログにllms.txt、llms-full.txt、WebMCPを試験的に実装した。効果を期待して先回りしたというより、人間以外にも内容を渡しやすいWebの形を、小さく試してみた理由と実装を整理する。"
lang: ja
url: "https://engineer-blog.tomoki-ttttt.workers.dev/articles/llms-txt-webmcp/"
publishedAt: "2026-06-07T14:00:00.000Z"
tags: ["AI", "WebMCP", "llms.txt", "Astro", "技術ブログ"]
projectIds: ["tech-blog"]
---
最近、検索やブラウザ、開発ツールの中で、AIがWebサイトの情報を読み取ったり、ユーザーの代わりに操作したりする場面が増えてきた。
それに伴って、「LLMO」「AI検索最適化」「AIエージェント対応」といった言葉も見かけるようになった。
ただ、現時点では何を実装すればAIから評価されるのか、どの仕様が広く利用されるのか、そもそも“最適化”と呼べる共通の正解があるのかは、まだ固まっていないと思っている。
そのため、今回このブログに `llms.txt`、`llms-full.txt`、WebMCPを実装したのは、「これを入れればAI検索に強くなる」と考えたからではない。
**人間以外がWebサイトを読む可能性が増える中で、HTMLとは別に、内容を機械へ渡しやすい入口を用意してみたかった**からだ。
今回は、実装した内容と、まだ効果も将来も決まり切っていない仕組みをなぜ先に試したのかを整理する。
## 人間向けの画面と、内容そのものは分けて考えられる
通常のWebページは、人間がブラウザで読むことを前提に作られている。
ヘッダー、ナビゲーション、目次、共有ボタン、関連記事、プロフィール、フッターなど、記事本文以外にも多くの要素が含まれている。
それらは人間にとって必要なUIだが、記事の内容だけを取得したいプログラムやAIにとっては、必ずしもすべてが必要とは限らない。
もちろん、現在のAIはHTMLから本文を抽出できる。だから、Markdown版を用意しなければ内容を読めないわけではない。
ただ、HTMLを解析して「どこからどこまでが本文か」を推測させるより、最初から記事タイトル、メタデータ、本文を含むMarkdownを渡せる方が単純だ。
このブログの記事は、もともとMarkdownで管理している。
そのため、人間向けにはHTMLを生成しつつ、必要であれば元の構造に近いMarkdownやJSONも返すという形は、比較的自然に実装できた。
人間向けUIをAI向けに作り替えるのではなく、**同じコンテンツに対して複数の表現を用意する**という考え方に近い。
## 今回実装したもの
今回追加したのは、主に次の3つだ。
* サイト全体の案内となる `llms.txt`
* 全記事をまとめた `llms-full.txt`
* 現在開いている記事のMarkdownを取得できるWebMCPツール
それぞれ役割が少し異なる。
## llms.txtは、AI専用のサイトマップというより「案内板」
このブログでは、ルートに [`/llms.txt`](/llms.txt) を配置した。
内容には、サイト名と説明に加えて、次の情報を含めている。
* 日本語記事の一覧
* 英語記事の一覧
* 各記事のMarkdown版URL
* Home、About、Works、Archiveなどの主要ページ
* 日本語・英語のRSS
* `llms-full.txt` へのリンク
イメージとしては、サイト内の全URLを機械的に並べるサイトマップよりも、**このサイトに何があり、どの情報から読むとよいかを示す案内板**に近い。
```txt
# ともきちのエンジニア成長記
> 学習、開発、失敗、改善のログを残すための個人技術ブログです。
## Articles (Japanese)
- [記事タイトル](https://example.com/articles/example.md): 記事の説明
## Pages
- [About](https://example.com/about/): Author profile and focus areas.
```
記事一覧を手作業で更新すると、記事を公開するたびに `llms.txt` の更新を忘れる可能性がある。
そこで、AstroのContent Collectionsから公開記事を取得し、ビルド時に一覧を生成するようにした。
```ts
const articles = await getArticlesWithPaths(locale);
return articles
.map(
({ article, slug }) =>
`- [${article.data.title}](${articleMarkdownUrl(locale, slug)}): ${article.data.description}`,
)
.join("\n");
```
記事のタイトルや説明は、通常の記事ページと同じfrontmatterを参照する。
これにより、AI向けの情報だけを別管理するのではなく、**人間向けページと同じ情報源から生成する**形にした。
ただし、`llms.txt` は現在提案されている形式の一つであり、すべてのAIサービスが読み取ることを保証するものではない。
少なくとも今の段階では、「配置すればAIからの流入が増えるファイル」というより、機械がサイトの構造を把握したいときに利用できる、任意の入口として考えている。
## llms-full.txtには、公開記事のMarkdownをまとめた
[`/llms-full.txt`](/llms-full.txt) には、日本語と英語の公開記事をMarkdownのまま連結している。
```ts
const body = articles
.map(({ article, slug }) => buildArticleMarkdown(article, locale, slug).trim())
.join("\n\n---\n\n");
```
`llms.txt` が記事へのリンクをまとめた索引だとすれば、`llms-full.txt` は本文までまとめたファイルだ。
この形式が必ず必要というわけではない。
むしろ記事数が増えれば、ファイルが大きくなりすぎて扱いにくくなる可能性もある。将来的には、カテゴリや言語ごとに分割した方がよいかもしれない。
それでも、現在のように記事数がまだ多くない段階では、サイト全体の内容を一度に確認したい用途に使える。
ここでも目的は検索順位を上げることではなく、**必要な側が扱いやすい形を一つ増やしておくこと**だ。
## 各記事はMarkdownとJSONでも取得できる
`llms.txt` から参照するため、各記事にはHTML版とは別にMarkdown版を用意している。
たとえば、通常の記事URLが次の形なら、
```txt
/articles/example/
```
Markdown版は次のURLで取得できる。
```txt
/articles/example.md
```
記事ページの`head`にも、Markdown版とJSON版を代替表現として記述している。
```html
```
これはAIだけのための機能ではない。
Markdownをそのままコピーしたい人、別のツールへ読み込ませたい人、JSONとしてメタデータを扱いたいプログラムにも利用できる。
「AI向け機能」として閉じるより、**コンテンツを再利用しやすくする一般的な出力形式**として考えた方が自然だと思っている。
## WebMCPでは、現在の記事を取得するツールだけを公開した
WebMCPは、Webページがブラウザ上で構造化されたツールを公開し、対応するAIエージェントがそのツールを呼び出せるようにするための仕様案だ。
このブログでは、記事ページを開いたときに `get_current_article_markdown` というツールを登録している。
役割は、現在表示している記事のMarkdownを返すことだけだ。
実装を単純化すると、次のようになる。
```ts
const modelContext = document.modelContext;
modelContext.registerTool({
name: "get_current_article_markdown",
description: "Return the full Markdown source of the article currently open in this tab.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
annotations: {
readOnlyHint: true,
},
async execute() {
const response = await fetch(markdownUrl);
const text = await response.text();
return {
content: [{ type: "text", text }],
};
},
});
```
検索、投稿、編集といった複数のツールを公開することも考えられるが、今回はそこまで広げなかった。
まずは、
* 入力を必要としない
* サーバー上の状態を変更しない
* 個人情報を扱わない
* すでに公開している記事を読み取るだけ
という、影響の小さいツールだけを実装した。
`readOnlyHint: true` も設定し、このツールが読み取り専用であることを示している。
また、WebMCPのAPIが存在しないブラウザでは、そのまま何もせず終了する。
```ts
if (!modelContext || !root) return;
```
そのため、未対応環境でも通常の記事閲覧には影響しない。
Astroのページ遷移で古い記事のツールが残らないように、ページを離れる前に`AbortController`で登録を解除し、次のページで改めて登録するようにもした。
このように、WebMCPをサイトの前提にはせず、**利用できる環境でだけ追加される段階的な機能**として扱っている。
## なぜ、まだ普及が決まっていない段階で実装したのか
正直なところ、`llms.txt`やWebMCPが今後どこまで普及するかは分からない。
仕様が変わる可能性もあるし、別の方法が主流になる可能性もある。
それでも実装した理由は、大きく4つある。
### 1. Webの利用者が人間だけとは限らなくなってきたから
これまでのWeb制作では、主に人間がブラウザで閲覧することを考えていればよかった。
しかし現在は、検索エンジンだけでなく、AIアシスタント、ブラウザエージェント、開発ツールなどがWeb上の情報を取得する場面も増えている。
将来の形を断定することはできないが、Webサイトが人間向けの画面だけを提供すれば十分なのかは、考えておく価値があると思った。
### 2. 元データがMarkdownなので、小さなコストで試せたから
このブログは、MarkdownをAstroでHTMLへ変換する構成になっている。
そのため、機械向けに内容を一から作り直す必要はなかった。
同じMarkdownからHTML、Markdown、JSON、`llms.txt`を生成し、WebMCPからもそのMarkdownを返せる。
大きなシステムを追加せず、既存のコンテンツモデルを利用して試せることは、個人ブログとの相性がよかった。
### 3. 効果を予想するより、実装して観察したかったから
新しい技術について考えるとき、普及してから対応するという選択肢もある。
一方で、小さく実装できるのであれば、実際に触った方が分かることも多い。
* どのような情報を構造化すればよいのか
* 人間向けページとの情報の重複をどう避けるか
* 仕様変更へ追従しやすい設計にできるか
* 読み取り専用と状態変更をどう分けるべきか
こうしたことは、記事を読むだけより、実装した方が具体的に考えられる。
今回の対応は将来への賭けというより、**変化を理解するための小さな実験**に近い。
### 4. 後から外しやすい形にできたから
新しい仕様を試すときは、導入しやすさだけでなく、外しやすさも大切だと思っている。
今回の実装は、既存の記事表示やURL構造を置き換えていない。
`llms.txt`と`llms-full.txt`は追加の出力であり、WebMCPもAPIが存在するときだけ動く。
仮に使われないまま終わったとしても、通常のブログ機能への影響は小さい。
普及が読めない技術だからこそ、中心に据えるのではなく、疎結合な追加機能として試した。
## これは「LLMO対策が完了した」という話ではない
今回の実装を、LLMO対策やAI検索最適化と呼び切ることには少し違和感がある。
そもそもLLMOという言葉が指す範囲も一定ではなく、AIサービスがWebサイトをどのように発見し、取得し、回答へ利用するかもサービスごとに異なる。
`llms.txt`を置いたからといって、AIの回答に引用されるとは限らない。
WebMCPを実装したからといって、一般的なブラウザやAIエージェントがすぐに利用できるわけでもない。
現時点では、どちらも広く確立した成果を約束するものではない。
そのため、このブログでは「AI向けに最適化した」というより、
**AIやプログラムが内容を取得するときに使える、機械可読な経路を試験的に用意した**
と表現する方が正確だと思っている。
## llms.txtはrobots.txtや利用許諾の代わりではない
もう一つ注意したいのは、`llms.txt`はアクセス制御の仕組みではないということだ。
`robots.txt`のようにクローラーへアクセス方針を伝えるものとも、コンテンツの著作権やAI学習への利用条件を定めるライセンスとも役割が異なる。
このブログの`llms.txt`は、サイトの内容と取得しやすい形式を案内するためのものだ。
何をクロールしてよいか、記事をどのように利用してよいかという問題は、robots.txt、利用規約、ライセンス、各サービスの挙動などを分けて考える必要がある。
名前だけを見るとAIに関するあらゆる方針を記述するファイルにも見えるが、少なくとも今回の実装では、**権限を与えるものではなく、公開済み情報への案内**として扱っている。
## WebMCPで状態を変更するなら、話は大きく変わる
今回公開したWebMCPツールは、記事のMarkdownを返すだけなので、できることは限定されている。
しかし、WebMCPでフォーム送信、購入、予約、投稿、設定変更などを扱う場合は、単にツールを登録するだけでは不十分だ。
通常のWeb UIと同じように、あるいはそれ以上に、
* 認証と認可
* 入力値の検証
* CSRFや不正リクエストへの対策
* 重要操作の確認
* 二重実行の防止
* レート制限
* 操作ログ
* ツールの説明と実際の動作の一致
などを考える必要がある。
AIエージェントが呼び出すからといって、信頼できるクライアントになるわけではない。
今回はまず、公開コンテンツを読むだけの機能に限定した。今後ツールを増やすとしても、便利そうだからという理由だけで状態変更を公開せず、通常のAPI設計と同じように境界を考えたい。
## 今後確認していきたいこと
実装した時点では、まだ「対応完了」ではない。
今後は、次のような点を確認していきたい。
* `llms.txt`やMarkdown版へのアクセスが実際に発生するか
* 記事数が増えたときに`llms-full.txt`が大きくなりすぎないか
* WebMCPの仕様変更へ無理なく追従できるか
* ブラウザやエージェント側の対応がどこまで広がるか
* 機械向けの説明が、人間向けの説明と矛盾していないか
ただし、アクセスログに記録があっても、それがAIによる利用なのか、単なる確認やクローラーなのかを正確に判断できるとは限らない。
数字が取れたとしても、すぐに効果と結びつけず、長い目で観察する必要があると思っている。
## おわりに
今回、技術ブログに`llms.txt`、`llms-full.txt`、WebMCPを実装した。
しかし、これは「これからはこの方法が正解になる」と断定したものではない。
AI検索やAIエージェント向けWebの形は、まだ変化の途中にある。
だからこそ、将来を決めつけて大きく作り込むのではなく、既存の人間向け体験を壊さない範囲で、機械にも情報を渡しやすい入口を追加した。
HTMLを読む人がいる。
Markdownを直接使いたい人がいる。
プログラムがJSONを取得することもある。
そして将来は、ブラウザ上のAIエージェントがWebMCPのツールを利用するかもしれない。
そのすべてを今から予測することはできない。
それでも、コンテンツと表示を分け、同じ情報を複数の形で安全に提供できる設計は、AIの流行に関係なく意味があると思っている。
今回の実装は、未知のランキングを攻略するための施策ではない。
**人間だけが利用者とは限らないWebを考えるための、小さな実験である。**
## 参考
* [The /llms.txt file](https://llmstxt.org/)
* [WebMCP Draft Community Group Report](https://webmachinelearning.github.io/webmcp/)
* [WebMCP is available for early preview](https://developer.chrome.com/blog/webmcp-epp)
---
---
title: "ログインなしアプリを作るときに考えたいセキュリティ設計"
description: "ログインをなくすと、本人確認や権限管理を別の仕組みで補う必要がある。開発を検討しているNobo Pageを例に、認可・セッション・CSRF・共有URL・トークン・XSSなどの設計上の論点を整理する。"
lang: ja
url: "https://engineer-blog.tomoki-ttttt.workers.dev/articles/login-less-app-security-design/"
publishedAt: "2026-06-05T12:00:00.000Z"
updatedAt: "2026-06-07T18:00:00.000Z"
tags: ["セキュリティ", "Web", "設計", "Nobo Page"]
projectIds: ["nobo-page"]
---
## はじめに
ログインなしで使えるアプリは手軽だ。
メールアドレスを入力したり、パスワードを設定したりする必要がなく、ページを開いてすぐに使い始められる。一時的なメモ、イベント案内、簡単な共有ボードなど、長く使い続けることを前提としないサービスとは特に相性がいい。
一方で、ログイン画面をなくせば設計も単純になる、とは限らない。
ログイン機能は、単にユーザー名を表示するためのものではなく、次のような役割も担っている。
- 誰がデータを作成したのかを確認する(認証)
- 誰が閲覧・編集・削除してよいかを判断する(認可)
- 紛失した権限を復旧する
- 不正な操作を特定する
ここで「認証(authentication)」と「認可(authorization)」は別の概念だ。認証は利用者やプロセス、端末の同一性を確認すること、認可はリソースへのアクセス権限を与えたり確認したりすることを指す。ログインをなくす場合、これらの役割を別の方法で補うか、機能自体を制限する必要がある。
この記事では、ログインなしアプリを設計するときに考えておきたいことを、現在開発を検討している **Nobo Page** を例に整理する。
## ログインなしでも「認可」は必要になる
ログインがあるアプリでは、ユーザーのアカウントを基準に権限を判断できる。
たとえば、「このユーザーはこのデータの作成者だから編集できる」という判断だ。ここではアカウントによる認証の結果を使って、認可を行っている。
ログインなしの場合、アカウントを使った本人確認(認証)はできない。確認できるのは「この人が作成者本人か」ではなく、多くの場合「正しい編集トークンを提示したか」「作成時と同じセッション識別子を持っているか」だ。
つまり、ログインなしで実際に行っているのは、人間の本人確認ではなく、**権限を示す秘密情報を所持していることの確認**に近い。代表的な手段は次のようなものだ。
- ブラウザのセッション
- Cookie
- ランダムに発行したトークン
- 閲覧用・編集用の専用URL
- 端末内に保存した識別情報
このように、URL自体が権限として機能するものは、一般に **Capability URL** と呼ばれる。「ログインをなくす」というより、アカウント認証を、秘密情報の所持にもとづく認可へ置き換えていると考えた方が近い。
なお、Capability URLという用語と考え方自体は妥当だが、よく参照されるW3Cの文書は2014年のFirst Public Working Draftであり、W3C勧告(Recommendation)ではない点には留意しておきたい。
## 共有しないなら、設計はかなり単純にできる
ログインなしアプリでも、データを他人と共有しないのであれば、考えることはかなり減る。
たとえば、データを作成したブラウザのセッションからしか閲覧できない設計だ。
```text
データを作成
↓
サーバーに保存
↓
作成時のセッションからだけ取得可能
```
この場合、データを開くための権限をURLに含める必要はない。
セッションを安全なCookieで管理すれば、URLが漏れただけでデータまで見られる問題を避けやすくなる。ただし「作成したブラウザからしか取得できない」というのは便宜的な説明で、実際にアクセスできるのは**そのセッションCookieを提示できるクライアント**だ。セッション識別子がコピーや窃取をされれば、別のブラウザからでも利用され得る。
そのため、Cookieを使うなら、最低限次のような点を押さえておきたい。
- Cookieに `Secure`、`HttpOnly`、適切な `SameSite` を設定する
- セッション識別子は暗号学的に安全な乱数で生成し、十分な長さ(128ビット以上が目安)にする
- 有効期限はCookieのMax-Age任せにせず、サーバー側でもセッションの寿命を管理する
### Cookieを使うならCSRFを忘れない
Cookieベースのセッションで見落としやすいのが **CSRF(クロスサイトリクエストフォージェリ)** だ。
ブラウザは対象サイトへのリクエストにCookieを自動で添付する。そのため、利用者が攻撃者のページを開いただけで、意図しない編集・削除リクエストを送らされる可能性がある。状態を変更するリクエストには、次のような対策を組み合わせたい。
- 編集・削除などの副作用をGETで実行しない
- フレームワークのCSRF保護やCSRFトークンを利用する
- `Origin` ヘッダーの検証やFetch Metadataを併用する
- `SameSite` 属性を適切に設定する
`SameSite` は多くのCSRFを抑制するが、すべての構成で単独の完全な対策になるわけではない。Cookieを使う以上、CSRFは別途考える必要がある。
なお、これでXSSの問題がなくなるわけではない。悪意のあるJavaScriptがページ内で動いてしまえば、Cookieの値を直接読めなくても、現在のセッションを使ってAPIにリクエストを送ることはできる。session-onlyな設計はリスクを小さくするが、XSS対策自体が不要になるわけではない。
## 難しくなるのは「ログインなしで共有する」とき
ログインなしアプリの設計が特に複雑になるのは、作成したデータを他人と共有できるようにするときだ。
共有する相手もアカウントを持っていない場合、サーバーは「この人は閲覧を許可された人なのか」をアカウントでは判断できない。
そこで、ランダムな文字列を含むURLを発行し、そのURLを持っていることを権限として扱う方法がある。
```text
閲覧用URLを持っている人 → 閲覧できる
編集用URLを持っている人 → 編集できる
管理用URLを持っている人 → 削除や設定変更ができる
```
便利な仕組みだが、このURLは単なるページの住所ではない。URLを持っていること自体が、パスワードを知っていることに近い状態になる。
## 「リンクを知っている人だけ」は非公開とは限らない
共有URLを使うサービスでは、「リンクを知っている人だけが閲覧できます」という説明がよく使われる。
ただし、この表現は少し注意が必要だ。
リンクを受け取った人は、さらに別の人へ転送できる。チャット履歴やメールにも残るし、画面共有やスクリーンショットに映ることもある。一度漏れたリンクを完全に回収することも簡単ではない。
そのため、共有URLを使うサービスで「安全に非公開です」と言い切るのは危険だ。より正確には、次のような状態である。
> このリンクを知っている人はアクセスできる。
利用者に対しては、機密情報や重要な個人情報の保存には向かないことも、分かりやすく説明する必要がある。
## 閲覧・編集・管理の権限を分ける
共有リンクを一つだけ発行し、そのリンクを持っている人が閲覧も編集も削除もできる設計は単純だ。しかし、リンクが漏れたときの被害も大きくなる。
そのため、用途に応じて権限を分ける方法がある。
```text
閲覧リンク
編集リンク
管理リンク
```
閲覧リンクでは内容を見ることだけを許可し、編集リンクでは内容の変更を許可する。削除や保存期限の変更など、影響が大きい操作は管理リンクに限定する。
Nobo Pageでも、共有機能を提供するなら、このように権限を分離する設計が候補になる。権限を分けておけば、閲覧リンクを広く共有した場合でも、編集や削除まで許可してしまうことを防げる。これは最小権限の考え方そのものだ。
## 権限トークンをそのままDBに保存しない
共有URLに含まれるランダムなトークンは、提示するだけで操作を許す秘密情報だ。そのため、トークンをそのままデータベースに保存するのは避けたい。
```text
共有URL:
https://example.com/page/123#edit=abc123...
DB:
edit_token = abc123...
```
これでは、データベースを閲覧できる人や、何らかの理由でデータが流出した場合に、そのまま編集権限を利用される可能性がある。
代わりに、サーバー側にはトークンのダイジェスト(ハッシュ値)だけを保存する。
```text
利用者が持つもの:
edit_token = CSPRNGで生成した128ビット以上のランダム値
DBに保存するもの:
edit_token_digest = HMAC-SHA-256(server_secret, edit_token)
```
ここで注意したいのは、**この方式はパスワードのハッシュ化とは目的が違う**ということだ。
利用者が記憶するパスワードは推測されやすいため、Argon2idやbcryptのような「あえて遅いハッシュ」が必要になる。一方、暗号学的に安全な乱数で生成した十分に長いトークンは、そもそも辞書攻撃や総当たりの対象になりにくい。そのため、トークンに対しては高コストなパスワードハッシュは必須ではなく、エントロピーの確保のほうが重要だ。
単純な `SHA-256(token)` でも、高エントロピーのトークンであれば総当たり耐性は確保できる。サーバー側の秘密鍵を使う `HMAC-SHA-256` にしておくと、DBだけが流出した場合に、秘密鍵を持たない攻撃者が同じダイジェストを計算しにくくなる。
実装方針としては、次の点も合わせて押さえたい。
- 暗号学的に安全な乱数生成器(CSPRNG)を使う
- トークンごとに権限と有効期限をサーバー側で結び付ける
- トークンを失効・ローテーションできるようにする
- 比較には、既存ライブラリの安全な(タイミング差の出にくい)比較関数を使う
- トークンをアクセスログ、分析基盤、エラー本文に出力しない
つまり「ハッシュ化すれば安全」なのではなく、これは**DB単体が漏洩したときの被害を軽減するための対策**だと理解しておくのが正確だ。利用者のブラウザには実際のトークンが存在するため、ブラウザ側の安全性も引き続き重要になる。
## URLフラグメントを使う理由と、その後の流れ
共有トークンをURLへ含める場合、クエリパラメータではなく、URLフラグメントを使う方法がある。
```text
https://example.com/page/123#edit=secret-token
```
`#` より後ろの部分は、通常のHTTPリクエストではサーバーに送信されず、`Referer` ヘッダーにも含まれない。そのため、CDNやWebサーバーのアクセスログにトークンがそのまま残ることを避けやすくなる。
ただし、サーバーが編集権限を検証するには、最終的にJavaScriptがトークンをサーバーへ送らなければならない。フラグメントに入れるだけでは話が完結しない点に注意したい。典型的には次の流れになる。
```text
/page/123#edit=secret-token を開く
↓
JavaScriptが location.hash を読む
↓
POST本文または Authorization ヘッダーでサーバーへ送る
↓
サーバーが検証する
↓
history.replaceState() でURLからトークンを除去する
```
ここで送ったトークンが、APM、WAF、アプリケーションログ、エラーレポートへ記録されないようにする必要がある。また、検証後は `history.replaceState()` などでアドレスバーと履歴からトークンを消し、共有や戻る操作で再び露出しないようにしておきたい。
より安全側に寄せるなら、生の編集トークンを毎回JavaScriptから送るのではなく、次のような構成も候補になる。
1. フラグメントのトークンを一度だけサーバーへ渡して交換する
2. その操作にスコープを限定した短期セッションを発行する
3. 以後は `HttpOnly` Cookieでそのセッションを使う
4. 元のトークンをURLから除去する
ただしこの構成ではCookieを使うことになるため、前述のCSRF対策がセットで必要になる。URLフラグメントは、あくまでトークンをサーバーログへ残しにくくするための一手段であり、それ自体でトークンが安全になるわけではない。
## 共有機能があるとXSSの影響が大きくなる
XSSは、ユーザーが入力した文字列などを安全に処理せず、HTMLやJavaScriptとして実行してしまう脆弱性だ。ログインなしの共有アプリでは、特に慎重に考える必要がある。
たとえば、攻撃者が次のような内容をボードに保存できたとする。
```html
```
この内容がそのままHTMLとして表示されると、共有リンクを開いた別の利用者のブラウザでスクリプトが実行される可能性がある。
共有しないsession-onlyなアプリであれば、保存した内容を一般の別利用者に踏ませる経路は狭くなる。ただし、これで Stored XSS が消えるわけではない。保存された内容は、次のような画面で別の人の目に触れることがある。
- 運営の管理・通報確認画面
- サポート対応画面
- プレビューやエクスポート機能
- 障害調査用の画面
- 将来追加される共有機能
これらの画面が保存内容を表示すれば、依然として他者に対する Stored XSS になり得る。さらに、Reflected XSSやDOM-based XSSは、共有機能の有無とは直接関係なく発生する。
主な対策としては、次のようなものがある。
- ユーザー入力をHTMLとして直接描画しない
- 文脈に応じた出力エンコード(HTMLエスケープ)を徹底する
- 安全なDOM APIを使い、`innerHTML` への生の代入を避ける
- 危険なURLスキーム(`javascript:` など)を拒否する
- 任意のスクリプトや埋め込みコードを許可しない
Content Security Policy(CSP)も有効だが、これは主たる対策ではなく**多層防御**の一つとして位置づけるのが正確だ。基本は、安全なDOM APIと、文脈に応じた出力エンコード、必要な場合だけのHTMLサニタイズである。
Markdownを扱う場合も、「Markdown入力をサニタイズしてからHTML化する」よりも、**MarkdownをHTMLへ変換した後に、そのHTMLを信頼できるサニタイザーへ通す**ほうが安全だ。変換の過程で危険なHTMLが生成される可能性があるためだ。
## HttpOnly CookieだけではXSSを防げない
セッション管理にHttpOnly Cookieを使うことは重要だ。HttpOnlyを設定すると、JavaScriptから `document.cookie` を使ってCookieを直接読み取ることができなくなる。
ただし、XSSが発生した場合、攻撃者のスクリプトは利用者と同じページ上で動作している。そのため、Cookieそのものを取得できなくても、ブラウザにCookieを自動送信させてAPIを呼び出せる。
```text
Cookieを盗む
→ HttpOnlyによって防ぎやすい
現在のセッションでAPIを操作する
→ XSSがあれば可能性が残る
```
HttpOnly Cookieは、セッション情報の持ち出しを防ぐための対策だ。XSSによる画面の読み取りや不正操作まで防ぐものではない。
## ブラウザ内に権限情報を残しすぎない
ログインなしアプリでは、利用者が再び同じデータを編集できるようにするため、権限情報をブラウザへ保存したくなることがある。たとえば、LocalStorageに編集トークンを保存する方法だ。
しかし、LocalStorageはページ上のJavaScriptから読み取れる。XSSが起きた場合、保存していた編集トークンや管理トークンを外部へ送信される可能性がある。これは sessionStorage や IndexedDB でも同様で、XSSに対しては同じように弱い。また、共有PCや他人の端末を使っている場合、その端末に権限情報が残り続ける問題もある。
そのため、何をブラウザへ保存するかは慎重に決める必要がある。言語設定やテーマ設定のような情報と、編集権限や管理権限は分けて考えるべきだ。
Nobo Pageでは、利便性のために権限を長期間保存するよりも、必要に応じて管理リンクを利用者自身に保管してもらう設計の方が安全側になる。
## 機密性のあるレスポンスはキャッシュさせない
閲覧・編集画面や、トークンを伴うAPIレスポンスは、内容に応じてキャッシュ制御を明示すべきだ。
何も指定しないと、ブラウザ、CDN、プロキシー、Service Workerなどに、機密性のあるレスポンスが残る可能性がある。共有端末で前の利用者の内容が表示される、といった事故にもつながる。
- 機密性のある応答には `Cache-Control: no-store` を検討する
- `no-cache` は「保存禁止」ではなく「再検証してから使う」という指示である点に注意する
- トークンを含むURLやレスポンスを、中間キャッシュに残さない
## 削除や権限変更をGETで実行しない
Capability URLを開いただけで、削除・公開・権限変更が実行される設計は避けたい。
リンクプレビューの生成、検索エンジンのクローラー、セキュリティスキャナーなどが、URLへ自動的にアクセスする可能性があるためだ。これらが副作用のあるURLを踏むと、意図しない削除や変更が起きてしまう。
状態を変える操作は、必ずPOSTなどの明示的なリクエストで行い、前述のCSRF対策と組み合わせる。「URLを開く」こと自体に破壊的な副作用を持たせないのが原則だ。
## 匿名アクセスならではの不正利用に備える
ログインがないと、誰がどれだけ使っているかを利用者単位で把握しにくい。そのため、匿名サービス特有の不正利用にも備えておきたい。
- スパム投稿や、大量のボード自動作成
- ストレージの消費を狙った濫用
- フィッシングページの作成・配布
作成・更新・閲覧の各APIには、IPだけに依存しすぎないレート制限、サイズや件数の上限、異常検知、そして通報・失効の手段を用意しておくと安全側になる。
将来的にファイル添付を提供するなら、種類とサイズの制限、Webルート外への保存、ウイルス検査、ダウンロード制御なども別途必要になる。
## 復元できることを前提にしない
ログインありのサービスでは、メールアドレスなどを使って本人確認し、アカウントやデータを復旧できる。
ログインなしの場合、管理リンクを紛失した利用者が本当の作成者かどうかを確認する手段がない。仮に「このボードを作ったのは自分です」と問い合わせがあっても、それを証明できなければ、管理権限を再発行するのは危険だ。
そのため、ログインなしの軽さを提供する代わりに、次のような制約が必要になる。
- 管理リンクを紛失すると復元できない
- 削除したデータは原則として戻せない
- 保存期間を過ぎたデータは復元できない
- 運営への問い合わせだけでは権限を再発行しない
また「自動削除する」「復元できない」と説明するなら、その範囲も実態と一致させたい。本体のデータを消しても、次のような場所にコピーが残っていれば、説明と矛盾してしまう。
- バックアップ
- CDNキャッシュ
- 検索インデックス
- サムネイルや変換済みファイル
- 監査ログや障害解析用データ
不便に見えるが、第三者へデータを渡さないために必要な制限でもある。
## 外部スクリプトは「画面」より「オリジン」で分ける
Webサービスには、アクセス解析、広告、ヒートマップ、チャットサポートなど、さまざまな外部スクリプトが追加される。外部スクリプトは、埋め込まれたページと同じ権限でJavaScriptとして動作するため、任意コード実行、機密情報の漏洩、提供元の侵害といったリスクを持ち込む。
そのため、すべてのページへ同じスクリプトを入れるのではなく、画面の役割によって分ける考え方がある。
ただし、単に「ボード画面には置かない」だけでは、両方が同じオリジンである限り隔離としては不十分なことがある。より強く分けるなら、オリジンそのものを分離したい。
```text
www.example.com
→ 紹介・広告・アクセス解析
app.example.com
→ ボードの閲覧・編集
→ 外部スクリプトは原則禁止
```
Web StorageやIndexedDBはオリジン単位で分離されるため、別オリジンにすればクライアント側のデータも隔離できる。あわせて、Cookieに不用意な `Domain=.example.com` を設定せず、必要なオリジンへ限定することも重要だ。
## Nobo Pageでは、共有する価値を残す
共有機能をなくし、session-onlyなアプリにすれば、Nobo Pageの設計はかなり単純にできる。URLに権限を持たせる必要もなく、閲覧・編集・管理リンクを分ける必要もない。
しかし、Nobo Pageでは、作成した内容をすぐ他人へ渡せることも価値の一つだ。イベント当日の案内、短期間だけ使う共有メモ、QRコードから開く情報ページなどは、作成者だけが閲覧できても用途を満たせない。
そのため、共有機能そのものをなくすのではなく、共有することで増えるリスクを前提に設計する必要がある。具体的には、次のような方針だ。
- 閲覧・編集・管理権限を分離する(最小権限)
- CSPRNGで128ビット以上のトークンを発行する
- 生の権限トークンをDBへ保存せず、ダイジェストで保持する
- 権限リンクに有効期限や失効手段を持たせる
- Cookieを使う場合は `Secure` / `HttpOnly` / `SameSite` とCSRF対策を設定する
- ユーザー入力を安全に表示する(出力エンコードとHTMLサニタイズ)
- 機密性のある応答をキャッシュさせない
- 状態変更をGETで実行しない
- 匿名利用に対するレート制限や濫用対策を用意する
- ボード画面の外部スクリプトを、できれば別オリジンで制限する
- 保存期間を必要以上に長くしない
- 機密情報向けではないことを明確に伝える
ログインなしという特徴だけで危険になるわけではない。ただし、ログインが担っていた認証や認可をなくす以上、その代わりになる仕組みを曖昧なままにしてはいけない。
## まとめ
ログインなしアプリは、利用開始までの負担を大きく減らせる。特に、一時的な用途や、アカウントを作るほどではない場面では、とても相性がいい。
一方で、ログインをなくすと、アカウントが担っていた認証や認可を別の方法で考える必要がある。共有しないsession-onlyなアプリであれば比較的単純な構成にできるが、それでもCookieを使う以上はCSRFやセッション管理を考える必要がある。ログインなしで他人とデータを共有する場合は、URLやトークンそのものが権限になる。
その場合は、次の点が重要だ。
- URLを単なる住所ではなく鍵として扱う
- 権限を必要以上に広くしない
- トークンを安全に生成・保存する
- XSSやCSRFによる権限漏洩・不正操作を防ぐ
- キャッシュや保存期間、復元範囲を限定する
- サービスの限界をユーザーへ正直に伝える
ログインなしの手軽さは、セキュリティを考えなくてよいという意味ではない。むしろ、ユーザーに意識させず安全に使ってもらうために、裏側では通常のログインアプリとは異なる設計が必要になる。
## 参考
- [Authentication — NIST Computer Security Resource Center Glossary](https://csrc.nist.gov/glossary/term/authentication)
- [Good Practices for Capability URLs(W3C Working Draft)](https://www.w3.org/TR/capability-urls/)
- [Cross-Site Request Forgery Prevention — OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [Session Management — OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
- [Cross Site Scripting Prevention — OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
- [Same-origin policy — MDN](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Same-origin_policy)
---
---
title: "AI時代に、あえて個人ブログをポートフォリオ化する理由"
description: "AIで何でも作れる時代だからこそ、「何を作ったか」だけでなく「どう考えて作ったか」を見せることが大切になる。個人ブログをポートフォリオとして育てていく理由と、その意図を整理する。"
lang: ja
url: "https://engineer-blog.tomoki-ttttt.workers.dev/articles/ai-era-portfolio-blog/"
publishedAt: "2026-05-28T23:00:00.000Z"
tags: ["ポートフォリオ", "個人開発", "AI", "ブログ運営"]
projectIds: ["tech-blog"]
---
最近、個人ブログをポートフォリオとしても使える形に育てていこうと考えている。
ポートフォリオサイトと聞くと、制作物を並べたり、使える技術を書いたり、自己紹介を載せたりするものを想像するかもしれない。もちろんそれも大切だ。
ただ、今の時代にポートフォリオサイトを作る意味は、少し変わってきているように感じている。
AIを使えば、Webサイトやアプリケーションの見た目はかなり簡単に作れるようになった。
おしゃれなデザイン、長大なコード、自己紹介ページ。そういったものは、以前よりもずっと短い時間で作れるようになっている。
だからこそ、単に「作れます」と見せるだけのポートフォリオは、以前ほど強い意味を持ちにくくなっているのかもしれない。
それでも私は、あえて個人ブログをポートフォリオ化しようと思っている。
理由は、これから重要になるのは「何を作ったか」だけではなく、**どういう意図で、どう考えて、どう作ったのか**だと思うからだ。
## AIで作れる時代だからこそ、意図が大事になる
AIによって、形にするハードルは大きく下がった。
アイデアを出す。
UIのたたき台を作る。
コードを書く。
文章を整える。
エラーの原因を探す。
こうした作業の多くは、AIの力を借りることでかなり速く進められる。
でも、だからといって「作る人の価値」がなくなるとは思っていない。
むしろ、何でもある程度作れるようになったからこそ、次に問われるのは、
* なぜそれを作るのか
* なぜその技術を選ぶのか
* なぜそのUIにするのか
* 何を優先して、何を捨てるのか
* どこをAIに任せて、どこを自分で判断するのか
* 作ったあとにどう改善していくのか
といった部分だと思う。
完成した成果物だけを見ると、AIで作ったものなのか、人間がどこまで考えたものなのかは分かりにくい。
でも、その背景にある意思決定や試行錯誤まで見えると、その人がどういう考え方で開発しているのかが伝わる。
私が作りたいポートフォリオは、単なる作品集ではなく、そうした**意思決定のログ**のような場所だ。
## 「作れる」だけではなく、「考えて作れる」を伝えたい
ポートフォリオを見る人が知りたいのは、完成品の見た目だけではないと考えている。
もちろん、見た目の完成度や技術的な実装力も大切だ。
でも実際の開発では、それ以上に「なぜそうしたのか」を説明できることが重要になる場面が多いはずだ。
たとえば、パフォーマンスを重視して静的な構成にするのか。
リッチな体験を優先して、あえて多少の重さを許容するのか。
アクセシビリティやセマンティックHTMLをどこまで丁寧に扱うのか。
AIを使うとしても、どこまでを任せて、どこからは自分で責任を持つのか。
こういう判断には、その人の価値観が出る。
私は、ただ「Next.jsを使えます」「Reactを書けます」「AIを使って開発できます」と言うだけではなく、
**自分が何を大事にして、どんな判断をしながら作っているのか**を伝えたい。
そのためには、通常のポートフォリオサイトよりも、ブログという形の方が合っていると感じた。
## 個人ブログは、完成品だけでは見えない部分を残せる
個人ブログの良さは、完成したものだけでなく、その途中の考えも残せるところだ。
たとえば、
* なぜそのサイトを作ったのか
* 最初はどういう設計にしていたのか
* 実装してみて何がうまくいかなかったのか
* どこを改善したのか
* どの技術選定に迷ったのか
* AIをどう使ったのか
* 今後どう育てていきたいのか
こうしたことを記事として残していける。
GitHubのリポジトリや完成したWebサイトだけでは、そこまでの背景はなかなか伝わらない。
でも、記事として言語化しておくことで、自分の考え方や成長の過程を見てもらえるようになる。
これは、就職活動や企業の方に見てもらうためだけではなく、自分自身にとっても意味がある。
過去の自分が何を考えていたのか。
何に悩んで、どう判断したのか。
どこが成長して、どこがまだ課題なのか。
そうした記録を残しておくことで、自分の開発者としての軸も少しずつ見えてくると思っている。
## AIを使うからこそ、人間側の判断を見せたい
私は、AIを使うこと自体を隠す必要はないと考えている。
むしろ、これからの開発ではAIをうまく使えることも重要なスキルの一つになるはずだ。
ただし、AIを使うことと、何も考えずにAIに任せることは違う。
AIにコードを書いてもらうとしても、最終的にそのコードを採用するかどうかを判断するのは自分だ。
AIが出したUI案を使うとしても、それが本当にユーザーにとって良い体験なのかを考えるのは自分だ。
AIに文章を整えてもらうとしても、そこに自分の考えがあるかどうかは自分で確認しなければならない。
だからこそ、AI時代のポートフォリオでは、完成物だけではなく、
**AIとどう向き合い、どこに自分の判断を置いているのか**を見せることが大切だと考えている。
「AIで作ったんでしょ?」で終わらせるのではなく、
「AIも使いながら、こういう意図で設計し、こういう判断で改善している」と伝えられる場所にしたい。
## 私にとってのポートフォリオは、作品集というより成長記録
今回、個人ブログをポートフォリオ化するにあたって、ただプロフィールページや制作実績ページを追加するだけにはしたくない。
もちろん、制作物や使っている技術は分かりやすく整理する。
でも、それだけではなく、それぞれの制作物に対して、
* どんな課題意識から作ったのか
* どこをこだわったのか
* 技術的に何を試したのか
* 何に失敗したのか
* 今後どう改善したいのか
まで残していきたい。
完成したものをきれいに並べるだけではなく、まだ途中のものも含めて、育っていく過程を見せる。
そうすることで、今の自分の実力だけではなく、
**どう学び、どう改善し、どう成長していく人なのか**を伝えられると思っている。
## このブログで見せていきたいもの
このブログでは、今後、自分が作っているWebサイトやアプリについても少しずつ整理していく予定だ。
たとえば、個人で運営している旅行ブログ、AIを活用した旅行計画アプリ、技術ブログ自体の改善、パフォーマンスチューニング、アクセシビリティ、UI設計、AIを使った開発フローなど。
ただ成果物を紹介するだけではなく、そこに至るまでの考えや、実装で悩んだこと、改善したことも含めて書いていきたい。
特に私は、Webの体験やパフォーマンス、UIの気持ちよさ、個人開発を継続的に育てていくことに関心がある。
そうした関心や価値観は、スキル一覧だけでは伝わりにくい。
だからこそ、このブログを通して、自分がどういうものを作りたい人なのか、どういう視点でWeb開発に向き合っているのかを伝えていきたいと考えている。
## おわりに
AIによって、何かを作ることはこれからさらに簡単になっていくと思う。
だからこそ、ただ作れることだけを示すのではなく、
**何を考え、何を選び、どう改善していくのか**を見せることが大切になる。
私にとって、個人ブログのポートフォリオ化は、そのための取り組みだ。
完成品を並べるだけの場所ではなく、考え方や試行錯誤、成長の過程まで残せる場所にする。
このブログを、そんなポートフォリオとして少しずつ育てていきたい。
---
---
title: "爆速技術ブログに Shiki でシンタックスハイライトを入れた理由"
description: "Astroで作った爆速技術ブログに、Shikiでシンタックスハイライトを導入した理由と、軽さ・読みやすさ・保守性のバランスについてまとめます。"
lang: ja
url: "https://engineer-blog.tomoki-ttttt.workers.dev/articles/add-shiki-syntax-highlight/"
publishedAt: "2026-05-21T16:30:00.000Z"
tags: ["Astro", "Shiki", "Web", "Performance", "技術ブログ"]
projectIds: ["tech-blog"]
---
## はじめに
このブログでは、できるだけ軽く、速く、読みやすい技術ブログを目指している。
基本方針はシンプルだ。記事本文は静的なHTMLとして出し、初回表示に不要なJavaScriptを絡めない。装飾や機能を増やすときも、それが本当に読む体験に必要かを考えるようにしている。
その方針の中で、今回 Shiki を使ってシンタックスハイライトを導入した。
## なぜシンタックスハイライトを入れたのか
最初は、シンタックスハイライトを入れるべきか少し迷っていた。
このブログは「爆速」を意識しているため、便利そうだからという理由だけでライブラリを追加したくなかった。コードブロックも、短いものであればプレーンテキストのままでも読める。
ただ、技術ブログを書いていく以上、コードや設定ファイルは避けられない。Astro、TypeScript、CSS、Cloudflare Workers、GitHub Actions などの記事では、複数行のコードや設定例を載せることになる。
そのときに、すべて単色のコードブロックだと視線の移動が増える。キーワード、文字列、コメント、プロパティの境界が分かりにくくなり、本文よりもコードを読む負担が大きくなる。
技術ブログにおいて、コードは本文の補足ではなく、理解を支える重要な情報である。であれば、必要最低限のシンタックスハイライトは読みやすさのために入れる価値があると判断した。
## なぜ Shiki を選んだのか
シンタックスハイライトの候補としては、Prism.js や highlight.js のような選択肢もある。軽量さだけを見れば、それらを選ぶ考え方もある。
それでも今回は Shiki を選んだ。
理由は、ビルド時にハイライト済みのHTMLを生成する方針と相性が良かったからだ。
このブログでは、記事本文をクライアント側で組み立てない。Markdownをビルド時にHTMLへ変換し、ブラウザにはできるだけ完成済みのHTMLを渡す。Shiki はこの考え方に合っている。
ブラウザ上でコードを解析して色を付けるのではなく、事前にハイライトしたHTMLを配信できる。つまり、読者が記事を開いたあとに、シンタックスハイライトのためだけに余計なJavaScriptを実行しなくてよい。
「コードを読みやすくしたいが、初回表示の負荷は増やしたくない」という今回の要件には、Shiki がちょうどよかった。
## 実際のコード例
Shiki では、たとえば次のようにコードを HTML に変換できる。
```ts
import { codeToHtml } from "shiki";
const html = await codeToHtml(`const message = "Hello, Shiki";`, {
lang: "ts",
theme: "github-dark",
});
console.log(html);
```
この処理はビルド時やサーバー側で実行できるため、記事を読むユーザーのブラウザでハイライト用の JavaScript を動かす必要がない。
このブログでは、こうした仕組みを使って、コードブロックを読みやすくしつつ、記事ページ自体は静的で軽いまま保つ方針にしている。
個人的には **GitHub Actions の YAML** がかなり相性いい。
理由は、単色だと読みづらいけど、ハイライトされると効果がわかりやすいから。
たとえば、設定ファイル系のコードもハイライトがあるだけでかなり読みやすくなる。
```yaml
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
```
## 自作しなかった理由
最初は、必要最低限のシンタックスハイライトを自作することも考えた。
たとえば、TypeScriptやJSONだけを対象にして、正規表現でキーワードや文字列に色を付けるくらいなら作れそうに見える。
しかし、実際にはすぐに限界が来る。
JavaScript、TypeScript、CSS、HTML、JSON、YAML、Shell などを扱い始めると、言語ごとの構文差を無視できなくなる。コメント、文字列、テンプレートリテラル、ネスト、エスケープ、属性、設定ファイル特有の書き方など、考えることが多い。
中途半端な自作ハイライトは、軽く見えても保守コストが高い。表示が少し崩れるたびに直す必要があり、記事を書く時間よりもハイライトの調整に時間を使ってしまう可能性がある。
このブログで大事にしたいのは、仕組みを作り込むことではなく、学習や開発の記録を読みやすく残すことだ。その目的から考えると、構文解析は Shiki に任せる方が自然だった。
## 依存関係を増やすことへの考え方
このブログでは、依存関係を増やしすぎないことを意識している。
ライブラリを1つ追加すると、サイズだけでなく、アップデート対応、設定、互換性、セキュリティ確認も増える。特にクライアント側に入る依存は慎重に見たい。
ただし、依存関係をゼロにすることが目的ではない。必要な機能まで無理に自作して、品質や保守性を落とすのは本末転倒である。
今回の Shiki は、導入する理由が明確だった。
- 技術記事のコードを読みやすくする
- ブラウザ側でハイライト処理を走らせない
- VS Code に近い自然な見た目にできる
- 自作よりも構文解析の精度と保守性が高い
- Astro の静的サイト構成と相性が良い
この条件を満たしているなら、依存関係を1つ増やす価値はある。
## 導入時に意識したこと
Shiki を入れるからといって、何でも盛るつもりはない。
このブログでは、あくまで静的で軽い記事ページを保ちたい。そのため、シンタックスハイライトも次の方針で扱う。
- 記事表示後にハイライト用JavaScriptを動かさない
- 必要な言語を中心に使う
- テーマや装飾を増やしすぎない
- コードブロックの見た目は読みやすさを優先する
- ページ全体の表示速度を犠牲にしない
大事なのは、Shiki を入れること自体ではない。コードを読みやすくしながら、ブログ全体の軽さを壊さないことである。
## おわりに
爆速な技術ブログを作るうえで、機能を足す判断は意外と難しい。
軽さだけを見れば、何も入れないのが一番だ。しかし、技術記事として読みづらくなってしまうなら、それは良いブログとは言えない。
今回 Shiki を導入したのは、見た目を豪華にするためではない。コードを自然に読めるようにしつつ、クライアント側の負荷を増やさないためである。
速く、軽く、読みやすく。
このバランスを崩さない範囲で、必要なものだけを慎重に選んでいきたい。
---
---
title: "Lighthouseの点数だけを追いかけると、体感速度を見失うかもしれない"
description: "個人技術ブログに CSS View Transitions を実装して気づいた、パフォーマンスの実測値と体感速度の違い。Lighthouse、MPA、SPA、アニメーションについて。"
lang: ja
url: "https://engineer-blog.tomoki-ttttt.workers.dev/articles/performance-and-transition-ux/"
publishedAt: "2026-05-16T17:00:00.000Z"
tags: ["performance", "frontend", "astro", "ux", "web"]
projectIds: ["tech-blog"]
---
## はじめに
最近、個人技術ブログを作り直している。
今回のブログでは、かなりパフォーマンスを意識している。
できるだけ軽く、できるだけ速く、できるだけ余計な JavaScript を持たない。
そんな方針で Astro を使い、静的なブログとして作っている。
最初はとにかく Lighthouse のスコアを見ていた。
FCP、LCP、TBT、CLS、Speed Index。
数字が良くなると嬉しいし、改善した実感もある。
ただ、作っていくうちに少し考えが変わってきた。
**Lighthouse の点数が良いことと、実際に使って気持ちいいことは、必ずしも同じではない。**
このことを実感したのが、SPA 風のページ遷移アニメーションを実装したときだった。
この記事では、その体験をもとに、パフォーマンスの実測値と体感速度の違いについて書いてみる。
## 最初は Lighthouse のスコアをかなり気にしていた
パフォーマンス改善をするとき、最初に見るものとして Lighthouse はかなり分かりやすい。
スコアが出る。
指標が出る。
改善すべき項目も出る。
特に個人開発では、何を改善すればいいか分からない状態になりやすいので、Lighthouse はかなりありがたい。
実際、自分も最初はかなり Lighthouse の点数を気にしていた。
- Performance が何点か
- FCP は何秒か
- LCP は何秒か
- TBT は出ていないか
- CLS は 0 に近いか
- Speed Index は悪くないか
こういう数字を見ながら、CSS を減らしたり、不要な JavaScript を削ったり、画像やフォントを見直したりしていた。
それ自体は悪いことではない。
むしろ、初期表示の改善にはかなり役立つ。
ただ、途中から少し違和感が出てきた。
## 点数は良いのに、なんか気持ちよくない
Lighthouse のスコアは良い。
初回表示も速い。
LCP も悪くない。
TBT もほぼない。
CLS も問題ない。
でも、実際に触ってみると、なんとなく気持ちよくないことがある。
たとえば、
- リンクを押してから画面が変わるまでに一瞬待つ
- ページ遷移が急に切り替わる
- クリックに対する反応が薄い
- 戻る・進むの体験が少し硬い
- 数字上は速いのに、体感では少し遅く感じる
みたいなことがある。
これは Lighthouse のスコアだけを見ていると気づきにくい。
Lighthouse は主にページの読み込み性能を評価する。
特に初回ロードの指標を見るにはとても便利。
でも、ユーザーが実際にサイトを使うときは、初回表示だけを見ているわけではない。
記事一覧から記事に移動する。
記事から別の記事に移動する。
トップに戻る。
タグページを開く。
何度もページを行き来する。
このときの体験は、Lighthouse のスコアだけでは測りきれない。
## 初回表示の速さと、ページ遷移の気持ちよさは別物
Webサイトの速さには、いくつか種類があると思う。
たとえば、
- 最初にページが表示される速さ
- 画像や本文が見えるまでの速さ
- クリックしてから反応があるまでの速さ
- ページ遷移が完了するまでの速さ
- スクロールや操作が引っかからない軽さ
- 操作していて気持ちいいかどうか
これらは似ているようで、少しずつ違う。
Lighthouse が得意なのは、主に最初の読み込みに関する評価だと思う。
もちろんそれはとても大事。
特にブログの場合、検索やSNSから直接記事に入ってくることが多いので、初回表示が速いことはかなり重要。
ただ、サイト内を回遊する体験まで考えると、初回表示だけでは足りない。
ページ遷移が気持ちいいか。
クリックした瞬間に反応があるか。
画面の切り替わりが自然か。
読んでいる流れを邪魔しないか。
このあたりも、かなり大事だと感じた。
## MPAの方が速い。でもSPAの方が速く感じることがある
今回のブログは、基本的には MPA 的な構成で作っている。
Astro を使って静的HTMLを生成し、必要以上にクライアントサイド JavaScript を持たない。
ブログとしてはかなり自然な構成だと思う。
MPA はシンプルで強い。
各ページが独立したHTMLとして配信されるので、初回表示が速くしやすい。
JavaScript への依存も少ない。
壊れにくい。
SEO やアクセシビリティの面でも扱いやすい。
個人ブログにはかなり向いていると思う。
一方で、SPA には SPA の強さがある。
ページ遷移時に画面全体を再読み込みしない。
必要なデータやコンポーネントだけを差し替える。
リンクを押した瞬間に反応を返しやすい。
遷移アニメーションも入れやすい。
その結果、実際の通信や処理の速度とは別に、**体感として速く感じる** ことがある。
これは結構大事だと思っている。
たとえば、MPA の方が実測では速かったとしても、ページがパッと白くなってから次のページが表示されると、ユーザーには少し遅く感じることがある。
逆に SPA では、実際には裏で多少処理が走っていても、クリック直後にアニメーションが始まったり、画面が滑らかに切り替わったりすると、体感としては速く感じる。
つまり、速度には **実測値としての速さ** と **体感としての速さ** がある。
この2つは近いけれど、完全には一致しない。
## CSS View Transitions を実装してみた
このブログに、実際に SPA 風のページ遷移を実装してみた。
使ったのは Astro の `ClientRouter` と、CSS の View Transitions API だ。
`ClientRouter` は、Astro が提供するクライアントサイドルーターで、ページ遷移時にフルリロードをなくして SPA 的な挙動を実現する。
ブラウザのネイティブな View Transitions API と組み合わせることで、ページ間で要素をスムーズに切り替えるアニメーションが実装できる。
やったことは大きく2つだ。
まず、ヘッダー・メインコンテンツ・フッターにそれぞれ `view-transition-name` を設定する。
これにより、ページ遷移時にどの要素をどう扱うかをブラウザに伝えられる。
```css
.site-header { view-transition-name: site-header; }
.site-main { view-transition-name: site-main; }
.site-footer { view-transition-name: site-footer; }
```
次に、ヘッダーとフッターはアニメーションなし(即時切り替え)にして、メインコンテンツだけに遷移アニメーションを当てる。
ヘッダーが毎回フェードすると落ち着かない。メインだけ動かすことで、「ページが変わった」という自然な文脈が生まれる。
```css
::view-transition-old(site-header),
::view-transition-new(site-header),
::view-transition-old(site-footer),
::view-transition-new(site-footer) {
animation: none;
}
```
この実装の前後で、Lighthouse のスコアはほとんど変わらなかった。
Performance、FCP、LCP、TBT、CLS——数字は動かない。
でも、実際にリンクをクリックしたときの体感はかなり変わった。
実装前は、クリックするとページが一瞬白くなり、次のページが表示される。
実装後は、コンテンツがフッと消えて、次のコンテンツが現れる。
ヘッダーはその間もずっとそこにある。
「速くなった」というより「引っかかりがなくなった」に近い感覚だ。
これが、実測値と体感速度の差を最も強く感じた体験だった。
Lighthouse では測れない部分に、体感の改善が確かにあった。
## アニメーションは悪ではない
パフォーマンス改善をしていると、つい「アニメーションは重いから削るべき」と考えがちになる。
もちろん、やりすぎたアニメーションは良くない。
- スクロールに追従して大量に動く
- JavaScript で重い計算をする
- レイアウトを何度も再計算させる
- 低スペック端末でカクつく
- 読む邪魔になる
こういうアニメーションは、体験を悪くする。
ただ、アニメーション自体が悪いわけではない。
むしろ、適切なアニメーションは体感速度を上げると思っている。
たとえば、
- リンクを押した瞬間に反応がある
- ページが自然に切り替わる
- 表示の変化に文脈がある
- いきなり切り替わらず、視線が迷わない
- 待ち時間が「待たされている感」になりにくい
こういう効果がある。
パフォーマンスの数字だけを見ると、アニメーションは余計なものに見えるかもしれない。
でも、ユーザー体験として見ると、むしろ必要な場合もある。
大事なのは、アニメーションを入れるかどうかではなく、**何のために入れるのか** だと思う。
装飾のためだけに入れるなら、削った方がいいかもしれない。
でも、操作へのフィードバックや画面遷移の自然さのために入れるなら、多少のコストを払ってでも入れる価値がある。
## 「速いサイト」と「気持ちいいサイト」は少し違う
今回ブログを作りながら思ったのは、速いサイトと気持ちいいサイトは少し違うということ。
速いサイトは、数字である程度見える。
FCP が速い。
LCP が速い。
TBT が小さい。
CLS がない。
JavaScript が少ない。
HTML が軽い。
こういうサイトは、確かに速い。
でも、気持ちいいサイトにはそれだけでは足りない。
クリックしたときにすぐ反応する。
遷移が自然。
スクロールが軽い。
画面の切り替わりに違和感がない。
読んでいる途中で集中が切れない。
こういう部分は、スコアには出にくい。
だから、パフォーマンス改善では Lighthouse だけではなく、実際に触ることが大事だと思った。
特にスマホで触る。
何度もページ遷移する。
戻るボタンを押す。
記事一覧から記事に入る。
記事を読み終わって別の記事に移る。
そういう普通の動きを何度も試して、気持ち悪いところを見つける。
これは地味だけど、かなり大事だと思う。
## 個人ブログでは、どこまでやるべきか
とはいえ、個人ブログで SPA のようなリッチな構成に寄せすぎるのも違うと思っている。
ブログは、まず文章を読む場所。
初回表示が遅くなったり、JavaScript が増えすぎたり、壊れやすくなったりするなら、本末転倒だと思う。
なので、今の自分の考えとしてはこんな感じ。
- 初回表示はできるだけ静的に速くする
- 基本は MPA 的なシンプルな構成にする
- ただしページ遷移の体感はちゃんと見る
- 必要なら軽いアニメーションを入れる
- prefetch はやりすぎず、効果がありそうなところに限定する
- SPA風の体験は、必要な範囲だけ取り入れる
全部を SPA にする必要はない。
でも、MPA だからといって、ページ遷移体験を諦める必要もない。
静的なブログでも、CSS View Transitions や限定的な prefetch を使えば、ある程度 SPA っぽい気持ちよさは作れる。
重要なのは、技術的な分類ではなく、実際に使ったときにどう感じるかだと思う。
## Lighthouseはゴールではなく、出発点
Lighthouse は便利だ。
パフォーマンス改善の入口としては、今でもかなり信頼している。
自分も今後も使うと思う。
ただ、Lighthouse のスコアを上げること自体をゴールにすると、少し危ない。
スコアは良いのに、使っていて気持ちよくない。
数字を守るために、必要なフィードバックまで削ってしまう。
初回表示だけを見て、ページ遷移や回遊体験を見落とす。
そういうことが起きるかもしれない。
だから今は、Lighthouse はゴールではなく、出発点だと思っている。
まず Lighthouse で明らかな問題を見つける。
そのうえで、実際に触って確認する。
数字と体感の両方を見る。
このバランスが大事だと思う。
## おわりに
個人技術ブログを作る中で、パフォーマンスについてかなり考えた。
最初は Lighthouse の点数を上げることばかり気にしていた。
でも、作って触っているうちに、ページ遷移の気持ちよさや、操作したときの反応も同じくらい大事だと感じるようになった。
Webサイトのパフォーマンスは、数字だけでは決まらない。
もちろん、FCP や LCP などの指標は大事。
でも、それと同じくらい、ユーザーが触ったときにどう感じるかも大事。
速いだけではなく、気持ちよく読めるブログにしたい。
今後も、初回表示の速さとページ遷移体験のバランスを見ながら、少しずつ改善していきたい。
---
---
title: "爆速な技術ブログを作るために意識していること"
description: "Astroで静的な技術ブログを作るときに意識した、HTML、JavaScript、CSS、画像、フォント、依存関係、Lighthouse計測の見方をまとめる。"
lang: ja
url: "https://engineer-blog.tomoki-ttttt.workers.dev/articles/tech-blog-performance-notes/"
publishedAt: "2026-05-16T15:00:00.000Z"
tags: ["Astro", "Web Performance", "Lighthouse", "技術ブログ"]
projectIds: ["tech-blog"]
---
## はじめに
このブログでは、できるだけ軽く、速く、読みやすい技術ブログを目指している。
この記事では、爆速な技術ブログを作るために、実装面で意識していることをまとめる。
具体的には、次のような内容を扱う。
- 記事本文を静的HTMLとして出すこと
- JavaScriptを初回表示に関与させすぎないこと
- CSSをクリティカルパスとして見ること
- 画像、アイコン、OGPを軽く扱うこと
- 日本語Webフォントを安易に読み込まないこと
- 依存関係を増やしすぎないこと
- Lighthouseの実測値をどう見るか
- デスクトップとモバイルを分けて確認すること
- 爆速ブログを保つためのチェックリスト
この記事は、フレームワーク選定の話というより、実際に技術ブログを軽く保つための実装メモに近い。
Astroで静的に出力しているブログを前提にしているが、Next.jsやViteなど、他の構成でブログやドキュメントサイトを作るときにも応用できる内容だと思う。
## 記事本文を静的HTMLとして出す
技術ブログで一番大事なのは、本文がすぐ読めることだ。
そのため、このブログでは記事本文をクライアント側で組み立てるのではなく、ビルド時に静的なHTMLとして生成する方針にしている。
Markdownで書いた記事を、ビルド時にHTMLへ変換しておく。
ブラウザは受け取ったHTMLをそのまま解釈できる。
JavaScriptの実行を待たなくても、本文を表示できる。
この形にしておくと、初回表示がかなり素直になる。
特に避けたいのは、記事本文の表示にJavaScriptを絡めすぎることだ。
たとえば、次のような構成にすると、ブログとしては必要以上に複雑になりやすい。
- 記事データをクライアント側でfetchする
- JavaScriptでMarkdownをパースする
- Reactなどで記事本文全体を描画する
- 初回表示時に状態管理やルーティング処理が必要になる
Webアプリならそれが必要な場面もある。
でも、技術ブログの記事ページは基本的に「読む」ためのページだ。
であれば、最初からHTMLとして置いておくのが一番速く、壊れにくいと思っている。
## HTMLを膨らませすぎない
静的HTMLにすればそれだけで完璧、というわけではない。
HTML自体が大きくなりすぎると、それはそれで初回表示に影響する。
技術ブログでは、記事本文、コードブロック、OGPメタ、構造化データ、多言語用のalternateリンクなど、意外とHTMLが膨らみやすい。
なので、HTMLでは次のような点を意識している。
- 不要なラッパー要素を増やさない
- 全ページ共通のコンポーネントを重くしない
- head内のメタタグを必要十分にする
- OGPやSEO用の情報を過剰に入れない
- 記事本文のHTMLサイズが不自然に大きくなっていないか確認する
特に、共通レイアウトは注意が必要だ。
ヘッダー、フッター、SEOコンポーネント、テーマ初期化、ナビゲーションなどは全ページに乗る。
ここが重くなると、すべての記事ページが重くなる。
ページ単体の実装だけでなく、共通レイアウトに何を入れているかを見るのが大事だ。
## JavaScriptを初回表示に関与させない
パフォーマンスを考えるうえで、JavaScriptはかなり慎重に扱っている。
JavaScriptはファイルサイズだけでなく、パース、コンパイル、実行、メインスレッドの占有まで含めてコストになる。
技術ブログでは、見た目上は小さな機能でも、積み重なるとかなり効いてくる。
たとえば、次のような機能だ。
- テーマ切り替え
- コードブロックのコピーボタン
- 目次の追従
- サイト内検索
- コメント欄
- アニメーション
- アクセス解析
- 外部ウィジェット
- シンタックスハイライト
これらは便利だが、全部を常設すると、記事を読むだけのページとしては重くなりがちだ。
なので、JavaScriptを入れるときは次のように考えている。
- そのJavaScriptは初回表示に本当に必要か
- HTMLとCSSだけで実現できないか
- 全ページで読み込む必要があるか
- その機能がなくても記事本文は読めるか
- メインスレッドをブロックしていないか
- 後から消しやすい実装になっているか
特に Astro の場合、必要なところだけクライアント側で動かすことができる。
ただし、アイランドアーキテクチャが便利だからといって、記事ページにどんどんインタラクティブなコンポーネントを足すと、結局普通の重いフロントエンドに近づいてしまう。
このブログでは、記事本文の表示にはJavaScriptを関与させないことを基本にしている。
テーマ初期化のように必要な処理はあるが、それもできるだけ小さく、早く終わるようにする。
## CSSをクリティカルパスとして見る
CSSは見た目を整えるだけのものではなく、初回描画に直接関わる。
CSSはレンダリングをブロックすることがある。
つまり、CSSが大きかったり、読み込みが遅かったりすると、HTMLが届いていても画面に描画されるまで待たされることがある。
このブログでは、CSSについて次のような点を意識している。
- 共通CSSを肥大化させない
- 記事ページに不要なトップページ用CSSを持ち込まない
- 複雑なレイアウトを避ける
- 重い装飾を多用しない
- アニメーションやtransitionを雑に全体適用しない
- モバイルでスクロールが重くならないようにする
特に注意したいのは、見た目のための重いCSSだ。
たとえば、次のようなものは使いすぎると負荷になりやすい。
- `box-shadow`
- `filter`
- `backdrop-filter`
- `blur`
- 大きなグラデーション
- 複雑なsticky要素
- 大量のtransition
- スクロール連動アニメーション
もちろん、これらを一切使わないという話ではない。
ただ、技術ブログでは本文を読むことが中心なので、装飾のためにスクロールや描画を重くするのは避けたい。
また、Astro側ではCSSの出力方法も見ている。
小さなCSSであればインライン化した方がリクエストを減らせることがあるが、CSSが大きくなると逆効果になる場合もある。
なので、CSSは「ファイルサイズ」だけでなく、レンダリングの邪魔をしていないかという視点で見ている。
## 画像・アイコン・OGPを軽くする
技術ブログでは、画像を使いすぎないことも重要だ。
旅行ブログやメディアサイトなら画像が主役になることもあるが、技術ブログの主役は基本的に本文だ。
画像が必要な場面はある。
スクリーンショットがあった方が分かりやすい記事もある。
OGP画像やアイコンも必要だ。
ただ、画像は簡単にページを重くする。
意識しているのは次のようなことだ。
- 不要なアイキャッチ画像を入れない
- 記事と関係ない装飾画像を増やさない
- スクリーンショットは必要な範囲だけ切り抜く
- WebPやAVIFなど軽い形式を検討する
- 画像のwidth / heightを指定してCLSを防ぐ
- preloadする画像を増やしすぎない
- OGP画像を過剰に巨大化させない
特に `preload` は便利だが、使いすぎると逆に他の重要なリソースを邪魔する可能性がある。
「早く読ませたいもの」を優先するための指定なので、何でも先読みすれば良いわけではない。
技術ブログの場合、最優先は本文だ。
画像を速く見せることより、本文が早く読めることを優先したい。
## フォントはシステムフォントを基本にする
日本語サイトでWebフォントを使うと、見た目は整いやすくなる。
ただし、日本語フォントはファイルサイズが大きくなりやすく、読み込みコストも無視できない。
技術ブログでは、ブランド表現よりも本文の読みやすさと表示速度を優先したい。
そのため、このブログでは基本的にシステムフォントを使う方針にしている。
システムフォントを使うメリットはシンプルだ。
- 追加のフォントリクエストが不要
- 表示が速い
- OSに馴染んだ見た目になる
- フォント読み込みによるちらつきが少ない
- レイアウト変化を抑えやすい
もちろん、Webフォントが悪いわけではない。
ブランドサイトやポートフォリオでは、フォントの印象が重要になることもある。
ただ、個人の技術ブログでは、まず読めること、速いこと、長く運用しやすいことを優先している。
## 依存関係を増やさない
高速化でかなり効くのが、依存関係を増やさないことだ。
ライブラリを1つ追加すると、それ自体のサイズだけでなく、関連する依存、ビルド設定、更新対応、セキュリティ対応も増える。
特にクライアント側に入るライブラリは慎重に見ている。
たとえば、次のようなものは便利だが、導入前に本当に必要か考える。
- 日付整形ライブラリ
- アニメーションライブラリ
- UIコンポーネントライブラリ
- 検索ライブラリ
- シンタックスハイライト
- Markdown拡張
- 画像ギャラリー
- コメントシステム
技術ブログでは、簡単な日付表示やタグ表示くらいなら自前で十分なことも多い。
検索機能やシンタックスハイライトも、記事数が少ない段階では必須とは限らない。
入れるとしても、全ページの初回表示に影響しない形にしたい。
依存関係を増やさないことは、速度だけでなく保守性にも効く。
軽いサイトを作るというより、重くなる入口を減らす感覚に近い。
## 静的アセットとして配信する
このブログでは、ビルドした静的ファイルを配信する構成にしている。
静的配信の良いところは、リクエスト時にアプリケーションサーバーでHTMLを生成しなくていいことだ。
DBアクセスも不要。
API呼び出しも不要。
ユーザーごとの動的生成も不要。
ビルド済みのHTML、CSS、JavaScript、画像をそのまま返せばいい。
この構成は、個人技術ブログとかなり相性が良い。
- 構成が単純
- 壊れにくい
- キャッシュしやすい
- 運用コストが低い
- セキュリティリスクを小さくしやすい
- サーバー側の処理時間に左右されにくい
Webアプリなら動的な仕組みが必要だ。
でも、記事を読むためのブログであれば、できるだけ静的に寄せた方が速く、運用も楽になる。
## Lighthouseの実測値を見る
高速化を考えるとき、Lighthouseはかなり便利だ。
ただし、Lighthouseの点数だけを見るのではなく、それぞれの指標を分けて見るようにしている。
このブログで測ったときの例は、だいたい次のような感じだった。
| 環境 | FCP | LCP | TBT | CLS | Speed Index |
|---|---:|---:|---:|---:|---:|
| Mobile 例1 | 0.8s | 0.9s | 0ms | 0 | 0.8s |
| Desktop 例1 | 0.4s | 0.4s | 0ms | 0 | 0.5s |
| Mobile 例2 | 0.9s | 0.9s | 0ms | 0.001 | 2.2s |
| Desktop 例2 | 0.3s | 0.3s | 10ms | 0.001 | 0.3s |
FCP、LCP、TBT、CLSはかなり良い値が出ている。
一方で、MobileのSpeed Indexだけが大きくブレることがあった。
ここが面白いところで、LCPが速いからといって、必ずしも画面全体の見え方が常に同じように速いとは限らない。
Lighthouseは一つの計測結果なので、実行するたびに多少ブレる。
ネットワーク状態、CPU、レンダリングタイミング、画像、CSS、外部要因などによって変わる。
なので、一回の結果だけで判断するのではなく、複数回測って傾向を見るようにしている。
## 静的な技術ブログで狙いたい目標ライン
これはあくまで個人的な目安だが、静的な技術ブログなら、かなり攻めた数値を狙えると思っている。
ChatGPTと相談しながら、静的な技術ブログ向けの理論値・目標値・許容ラインを整理すると、だいたい次のようになる。
| 指標 | 理論値 | 現実的な目標 | 許容ライン |
|---|---:|---:|---:|
| FCP | 0.2〜0.5s | 0.5〜1.0s | 1.5s未満 |
| LCP | 0.3〜0.8s | 0.8〜1.5s | 2.0s未満 |
| TBT | 0ms | 0〜50ms | 100ms未満 |
| CLS | 0 | 0〜0.01 | 0.05未満 |
| Speed Index | 0.3〜0.8s | 0.8〜1.5s | 2.5s未満 |
ただし、これはかなり軽いサイト前提だ。
たとえば、次のようなサイトではこの目標は厳しすぎると思う。
- 広告が多いメディアサイト
- 画像が主役の旅行ブログ
- ECサイト
- ダッシュボード
- ログイン後のWebアプリ
- 外部スクリプトが多いサイト
- 大量のWebフォントを使うサイト
このブログのように、Astroで静的HTMLを生成し、画像やJavaScriptをかなり抑えた技術ブログだからこそ、このくらいの数値を目指せるという感覚だ。
## デスクトップとモバイルは別物として見る
デスクトップで速いからといって、モバイルでも速いとは限らない。
これは実際に測っていてかなり感じた。
デスクトップではFCPやLCPが0.3〜0.4秒台で出ることがある。
一方で、モバイルでは同じページでもSpeed Indexが2秒台になることがあった。
理由はいろいろ考えられる。
- CPU性能が違う
- ネットワーク条件が違う
- 画面サイズが違う
- CSSの適用タイミングが違う
- フォント描画の影響が違う
- 画像の扱いが違う
- モバイルのLighthouse条件が厳しい
特に個人開発では、開発中の確認をPCで済ませがちだ。
でも、実際に読む人はスマホで見ているかもしれない。
だから、デスクトップだけ見て「爆速」と判断しないようにしている。
最低でも、LighthouseのMobileとDesktopは両方見る。
できれば、実機やDevToolsのPerformanceも見る。
このくらいは習慣にしておきたい。
## 数字だけに最適化しない
Lighthouseは便利だが、Lighthouseの数字だけに最適化しすぎるのは少し危険だと思っている。
最初は、FCP、LCP、TBT、CLS、Speed Indexの数値をかなり重視していた。
もちろん、これらは初回表示の状態を把握するうえで重要だ。
特に、静的な技術ブログでは悪い数値が出たときに原因を追いやすいので、Lighthouseはかなり役に立つ。
ただ、サイトの体験は一つのスコアだけでは判断できない。
たとえば、LCPが良くても、スクロールが重いことはある。
TBTが低くても、モバイルでの表示が安定しないことはある。
Desktopではきれいな数値が出ていても、MobileではSpeed Indexが大きくブレることもある。
だから、Lighthouseを見るときは合計スコアだけでなく、次のような観点も合わせて確認するようにしている。
- FCP、LCP、TBT、CLS、Speed Indexを個別に見る
- MobileとDesktopを分けて見る
- 一回の計測結果だけで判断しない
- Networkでリクエスト数と転送サイズを見る
- Coverageで未使用のCSSやJavaScriptを見る
- Performanceでメインスレッドの詰まりを見る
- 実際にスクロールして重くないか確認する
Lighthouseは、問題を見つけるための道具としてはとても優秀だ。
ただ、最終的な目的はスコアを上げることではなく、読者がストレスなく記事を読める状態にすることだ。
数字は見る。
でも、数字だけで判断しない。
このバランスを忘れないようにしたい。
## 爆速技術ブログのためのチェックリスト
最後に、技術ブログを軽く保つためのチェックリストをまとめる。
### HTML
- [ ] 記事本文はビルド時にHTML化している
- [ ] 記事本文をクライアント側JavaScriptで生成していない
- [ ] 不要なラッパー要素を増やしていない
- [ ] 共通レイアウトが重くなっていない
- [ ] head内のメタタグが過剰になっていない
- [ ] OGP、canonical、hreflangなどは必要十分にしている
- [ ] HTMLサイズが不自然に大きくなっていない
### JavaScript
- [ ] 初回表示に不要なJavaScriptを読み込んでいない
- [ ] 記事ページにReactなどのハイドレーションを安易に入れていない
- [ ] 記事本文の表示にJavaScriptが必須になっていない
- [ ] テーマ初期化など必要な処理は小さく保っている
- [ ] 外部スクリプトを増やしていない
- [ ] コピー機能、検索、目次追従などを入れる前にコストを考えている
- [ ] DevToolsでメインスレッドを長時間ブロックしていないか確認している
### CSS
- [ ] 共通CSSが肥大化していない
- [ ] 記事ページに不要なトップページ用CSSを持ち込んでいない
- [ ] CSSがレンダリングをブロックすることを意識している
- [ ] `box-shadow`、`filter`、`backdrop-filter`、`blur` を多用していない
- [ ] アニメーションやtransitionを全要素に雑に適用していない
- [ ] sticky要素や重い装飾でスクロールが重くなっていない
- [ ] モバイルでスクロールが重くなっていない
### 画像・アイコン
- [ ] 不要なアイキャッチ画像を入れていない
- [ ] 画像はWebPやAVIFなど軽い形式を検討している
- [ ] 画像のwidth / heightを指定している
- [ ] スクリーンショットは必要な範囲だけにしている
- [ ] preloadする画像を増やしすぎていない
- [ ] favicon、アイコン、OGP画像を過剰に増やしていない
- [ ] 画像が本文表示より優先されすぎていない
### フォント
- [ ] 日本語Webフォントを安易に読み込んでいない
- [ ] システムフォントで十分か検討している
- [ ] フォント読み込みによる表示遅延を確認している
- [ ] フォント差し替えによるレイアウト変化を確認している
### 依存関係
- [ ] 便利ライブラリを追加する前に本当に必要か考えている
- [ ] クライアント側bundleに入る依存を特に慎重に見ている
- [ ] 日付整形やタグ表示など、軽い処理をライブラリに頼りすぎていない
- [ ] 検索機能を入れる場合、indexサイズやJSサイズを確認している
- [ ] シンタックスハイライトを入れる場合、全ページに余計な負荷をかけていない
- [ ] 使っていない依存関係を残していない
### 配信・ビルド
- [ ] 静的ファイルとして配信できる構成になっている
- [ ] DBやAPIを初回表示に絡めていない
- [ ] ビルド済みのHTML/CSS/JS/画像をそのまま配信している
- [ ] キャッシュしやすいファイル構成になっている
- [ ] デプロイ後の成果物サイズを確認している
- [ ] CIでbuild、lint、typecheckを確認している
### 計測
- [ ] LighthouseのMobileとDesktopを両方見る
- [ ] FCP、LCP、TBT、CLS、Speed Indexを個別に見る
- [ ] 一回の計測結果だけで判断していない
- [ ] Networkでリクエスト数とサイズを見る
- [ ] Coverageで未使用CSS/JSを見る
- [ ] Performanceでメインスレッドの詰まりを見る
- [ ] 実機またはモバイル相当環境でスクロールの重さを見る
- [ ] デスクトップだけ速くなって満足していない
- [ ] Lighthouseの数字だけで体験を判断していない
## おわりに
爆速な技術ブログを作るために必要なのは、魔法の設定を一つ入れることではなく、小さな負荷を増やさないことの積み重ねだと思っている。
静的HTMLとして本文を出す。
JavaScriptを初回表示に関与させすぎない。
CSSをクリティカルパスとして見る。
画像やフォントを慎重に扱う。
依存関係を増やしすぎない。
デスクトップとモバイルを分けて計測する。
こうした地味な判断の積み重ねが、結果として速く、軽く、長く運用しやすいブログにつながる。
ただし、Lighthouseの数字だけを追えばいいわけではない。
初回表示が速くても、ページ遷移が遅く感じることはある。
デスクトップで速くても、モバイルで遅くなることもある。
一つのスコアだけでは、サイト全体の体験は判断できない。
だからこそ、数字は見つつも、最後は実際の体感も確認する。
このバランスを忘れずに、今後もこのブログを軽く保っていきたい。
---
**追記(2026-05-21):** その後、このブログに Shiki を使ってシンタックスハイライトを導入した。「軽さを崩さない形で入れること」を判断基準にした経緯については、[爆速技術ブログに Shiki でシンタックスハイライトを入れた理由](/articles/add-shiki-syntax-highlight/)にまとめている。
---
---
title: "AI旅行プランナーの生成の待ち時間に『旅のTips』を出すUXを考えた話"
description: "AI旅行プランナーTabideaで、生成の待ち時間を単なるローディングではなく、プログレスバーと旅のTipsで期待を高める時間に変えるために考えたこと。"
lang: ja
url: "https://engineer-blog.tomoki-ttttt.workers.dev/articles/tabidea-travel-tips-loading-ux/"
publishedAt: "2026-05-11T19:00:00.000Z"
tags: ["AI", "UX", "個人開発", "旅行", "Tabidea"]
projectIds: ["tabidea"]
---
AIを使ったアプリを作っていると、どうしても避けて通れない問題がある。
それが、**AIの生成時間**だ。
チャットのように短い返答を返すだけならまだしも、ある程度まとまった結果を生成するアプリでは、ユーザーに数秒から十数秒、場合によってはそれ以上待ってもらうことがある。
私は現在、**Tabidea** というAI旅行プランナーを個人開発している。
行き先や日数、好みなどを入力すると、AIが旅行プランを作ってくれるWebアプリだ。
https://tabide.ai
技術スタックとしては、フロントエンドに **Next.js / React / TypeScript** を使っている。
AIによる旅程生成には **Gemini API** を利用し、旅程生成後にスポット情報や移動の現実性を補うために、**Google Maps API** などの外部APIも組み合わせる構成を考えている。
また、認証やユーザーデータの管理には **Supabase** を使っている。
つまりTabideaは、単にLLMへプロンプトを投げて文章を返すだけのアプリではなく、AI生成・外部API・ユーザー状態・UI表示を組み合わせて成立するWebアプリだ。
最初は、「AIに条件を渡せば、いい感じの旅程を返してくれるだろう」と考えていた。
ただ、実際に作ってみると、旅行プランナーでは単に文章を生成するだけでは足りなかった。
旅程として成立させるためには、たとえば次のようなことを考える必要がある。
* その場所が本当に存在するか
* スポット同士の距離感が現実的か
* 移動時間に無理がないか
* 営業時間や定休日と矛盾していないか
* 朝・昼・夜の流れとして自然か
* ユーザーの希望条件に合っているか
つまり、旅行プランの生成は「それっぽい文章を返す」だけでは終わらない。
AIが提案した内容を、現実の移動やスポット情報と照らし合わせながら、できるだけ自然な旅程として成立させる必要がある。
その結果、どうしても生成に時間がかかる。
この記事では、AI旅行プランナーを作る中で、生成の待ち時間をどう扱うか考えた話を書く。
最終的に私は、単なるローディングスピナーではなく、**プログレスバーと旅のTipsを表示するUI**にたどり着いた。
## AIアプリにおける待ち時間の問題
AIを使ったアプリでは、ユーザーが入力してから結果が返ってくるまでの間に、どうしても待ち時間が発生する。
普通のWebアプリでも、データベースの読み込みや外部API通信の待ち時間はある。
ただ、AI生成の待ち時間はそれとは少し性質が違う。
DBや通常のAPIであれば、取得対象や処理内容がある程度決まっている。
一方でAI生成は、入力内容によって出力の量や処理時間が変わりやすく、ユーザーから見ると**今何が起きているのか分かりにくい**。
ボタンを押したあと、画面が止まったように見える。数秒待っても変化がない。するとユーザーは、
* ちゃんと動いているのか
* エラーになっていないか
* もう一度押した方がいいのか
* いつまで待てばいいのか
と不安になる。
しかも、私が作っているのは**Webアプリ**だ。
Webアプリは、特にモバイルアプリと比べると、待ち時間に対してユーザーが離脱しやすいと感じる。
モバイルアプリであれば、多少待っても「アプリが処理している」と受け止めてもらいやすい場面がある。
一方でWebアプリは、ページの表示や遷移に少しでも引っかかりがあると、ユーザーはすぐに「重い」「止まったかもしれない」と感じやすい。
さらにWebアプリでは、ユーザーが**再読み込みしやすい**という難しさもある。
少し不安になると、ブラウザのリロードボタンを押したり、戻るボタンを押したり、別タブに移動したりしやすい。
技術的にも、生成中の状態をどこに持つかは少し難しくなる。
クライアント側のstateだけで生成中の状態を持っていると、リロードされた時点でその状態は失われる。かといって、すべてをジョブ化してサーバー側で進捗管理するほど大げさにすると、個人開発の規模では実装コストが一気に上がる。
だからこそ、まずは「ユーザーが不安にならずに待てる画面」を作ることが重要だと感じた。
生成結果がどれだけ良くても、待っている間の体験が悪いと、その前に離脱されてしまうかもしれない。
AIアプリにおいて、生成の待ち時間の体験はかなり重要だ。
## 代表的な解決策はストリーミング
AIアプリの待ち時間に対する代表的な解決策の一つが、**ストリーミング**だ。
ChatGPTのように、文章が少しずつ表示されていくUIだ。
ストリーミングには大きなメリットがある。
まず、ユーザーは「ちゃんと生成が進んでいる」と分かる。
結果が少しずつ出てくるので、待たされている感覚がかなり軽減される。
また、すべての生成が終わる前に読み始めることができる。
文章生成系のアプリでは、ストリーミングはとても相性が良いと思う。
ただ、私が作っている旅行プランナーでは、ストリーミングをそのまま使うのは難しいと感じた。
## 旅行プランナーではストリーミングしにくい理由
旅行プランナーの場合、生成途中の情報をそのままユーザーに見せるのは少し危険だ。
たとえば、AIが最初に「1日目の午前はAに行く」と出したとしても、その後のチェックで、
* Aが実在しない
* Aの営業時間に間に合わない
* 次のスポットBまでの移動が現実的ではない
* そもそもAより別のスポットの方が自然
といったことが起こりえる。
旅行プランは、単なる文章ではなく、順番や移動、時間配分がつながった構造だ。
途中まで出した内容が、後から変わる可能性がある。
技術的にも、Tabideaで扱いたい旅程は、ただの長いテキストではなく、日程・時間帯・スポット・説明・移動などを持った**構造化データ**だ。
そのため、トークンが少しずつ流れてきたからといって、それをそのままUIに出せば良いわけではない。
部分的なJSONや未検証のスポット名を表示してしまうと、ユーザーにとってはかえって分かりにくくなる。
最初に表示されたスポットが後から消えたり、順番が入れ替わったりすると、「さっき出ていた内容は何だったのか」となってしまう。
また、旅行プランでは「それっぽい」だけでは不十分だ。
地名や観光地名は特に、存在しない場所を出してしまうと致命的だ。
さらに、実在する場所でも、1日の移動として無理があれば、旅行プランとしては使いにくくなる。
そのためTabideaでは、AIの出力をそのままストリーミング表示するよりも、ある程度チェックしたうえで、まとまった旅程として表示する方が自然だと考えた。
## 旅程生成は「AIの返答待ち」だけではない
旅行プランナーの生成時間が長くなる理由は、AIの返答を待っているだけではない。
Tabideaでは、LLMが作った旅程案をそのまま画面に出すのではなく、必要に応じて外部APIやアプリ側のロジックと組み合わせながら、ユーザーに見せられる形に整えていく。
そのため、待ち時間には「AIが考えている時間」だけでなく、「アプリ側で検証・整形している時間」も含まれる。
旅程を作るには、AIの出力に加えて、その内容が実際の旅行として成立するかを確認する必要がある。
大まかには、次のような処理が必要になる。
1. ユーザーの希望条件を整理する
2. 候補となるスポットやエリアを考える
3. 日程ごとの流れを組み立てる
4. スポットの実在性や場所情報を確認する
5. 移動時間や順番に無理がないか確認する
6. 最終的にユーザーに見せる形に整える
もちろん、すべてを完璧に検証するのは簡単ではない。
ただ、旅行プランナーとして使ってもらう以上、少なくとも「明らかに存在しない場所」や「現実的ではない移動」をできるだけ減らしたいと思っている。
このようなチェックを挟むと、どうしても処理時間は伸びる。
しかし、その時間は単なる遅延ではなく、旅程の品質を上げるために必要な時間でもある。
問題は、その必要な時間がユーザーには見えないことだ。
裏側では、AI生成やスポット確認、旅程の整形をしていても、画面上では何も起きていないように見えてしまう。
ここをどう見せるかが、UXとして大事だと感じた。
## 最初はスケルトンやローディングスピナーを使っていた
最初に試したのは、よくある待ち時間用のUIだ。
具体的には、
* ローディングスピナー
* スケルトンUI
* 「生成中です」といったテキスト
を表示していた。
これは実装しやすく、最低限「今処理中である」ことは伝えられる。
ただ、実際に自分で使ってみると、少し物足りなさがあった。
スピナーは、動いていることは伝えてくれる。
でも、どれくらい待つのか、今何が進んでいるのかは分からない。
スケルトンUIも、通常の一覧画面やカード表示の読み込みには向いている。
ただ、AI生成のように「今まさに何かを考えている」「これから結果が組み上がる」体験とは、少し相性が違うように感じた。
特に旅行プランナーの場合、ユーザーはこれから行くかもしれない旅を想像しながら待っている。
その時間を、ただの待ち時間として消費させるのは少しもったいないと思った。
## プログレスバーを入れるようにした
そこで次に、**プログレスバー**を表示するようにした。
もちろん、AI生成の進捗は厳密にパーセンテージで表せるものではない。
「今37%です」と正確に言えるわけではないし、APIの応答時間や検証処理によって所要時間も変わる。
そのため、ここでのプログレスバーは、厳密な進捗表示というよりも、**体感上の不安を減らすための進捗表現**だ。
ユーザーにとっては、何も変化しない画面より、進んでいる雰囲気がある方が安心できる。
重要なのは、進捗を完全に正確に見せることではなく、
* 処理が止まっていないこと
* 生成にはいくつかのステップがあること
* 少しずつ完了に近づいていること
を伝えることだと思った。
旅行プランナーでは、旅程生成は単純な1回の生成ではない。
スポット候補を考え、並び順を調整し、実在性や移動の現実性も見ながら、全体として無理のない旅程にする必要がある。
そうした裏側の処理をユーザーにそのまま見せるわけではなくても、「ちゃんと組み立てている途中なんだ」と伝えることには意味があると感じた。
## 旅のTipsを表示する案を思いついたきっかけ
さらに、生成の待ち時間に**旅のTips**を表示することを考えた。
この案を思いついたきっかけは、ゲームだった。
ゲームでは、ダウンロード中やロード中に、ちょっとしたヒントや豆知識が表示されることがある。
プレイヤーは待っているだけなのに、その時間が少しだけ意味のあるものになる。
ただ待つだけではなく、世界観に触れたり、知識を得たりできる。
私はこの体験が結構好きだった。
そこで、「これをAI旅行プランナーにも応用できるのではないか」と思った。
たとえば旅程生成中に、
* 旅先で使える小さなコツ
* 現地を楽しむための視点
* 旅行前に知っておくと便利なこと
* 文化や観光の豆知識
のような短いTipsを表示する。
これなら、ユーザーは待っている間にも少し旅の気分になれる。
単なる「処理中です」ではなく、「これから旅が始まる」ような感覚を作れるかもしれない。
## WebアプリやAIアプリでは意外と珍しい
ゲームのロード画面ではよく見る表現だが、WebアプリやAIアプリでは、待ち時間にTipsをしっかり見せるUIはそこまで多くない印象がある。
多くの場合は、
* スピナー
* スケルトン
* 生成中テキスト
* ストリーミング表示
のどれかだ。
もちろん、これらが悪いわけではない。
むしろ、多くの場面ではそれで十分だと思う。
ただ、旅行プランナーのように、ユーザーが「これから行く場所」を想像しているアプリでは、待ち時間の数秒も体験の一部にできるのではないかと思った。
AIの生成時間は、開発者としてはなるべく短くしたいものだ。
でも、どうしてもゼロにはできない。
しかもWebアプリでは、その待ち時間が長いほど、リロードや離脱のきっかけにもなりやすい。
だからこそ、ただ待たせるのではなく、「待つこと自体に意味がある」状態にできるなら、その価値は大きいと感じた。
## 実際に採用した実装
最終的に、Tabideaでは待ち時間に**プログレスバーと旅のTipsを表示する**形にした。
スケルトンやローディングスピナーも試したが、それだけでは待っている時間が少し味気なく感じた。
そこで、進行していることを伝えるためのプログレスバーを出しつつ、その下に短い旅のTipsを表示するようにした。
実装としては、ユーザーが旅程生成を開始したタイミングで待ち時間用の画面に切り替え、生成処理が完了するまでプログレスバーとTipsを表示する。
Tipsは固定の文言を出しっぱなしにするのではなく、いくつかの短い文を切り替えて表示する形にした。
また、旅程生成中にユーザーが「本当に動いているのか」と不安にならないように、画面上には単なるスピナーだけでなく、処理が進んでいるように感じられる要素を置いている。
これによって、ユーザーにとっては、
* ちゃんと処理が進んでいることが分かる
* ただ待つだけではなく、ちょっとした情報に触れられる
* 旅程ができあがるまでの時間も旅行体験の一部のように感じられる
という状態を作りやすくなったと思う。
とくに旅行というテーマでは、結果そのものだけでなく、旅を想像している時間にも価値がある。
そのため、待ち時間に旅のTipsを出すことは、単なる暇つぶしではなく、プロダクトの体験そのものに合っていると感じた。
## Tipsを出すときに気をつけたいこと
ただし、Tipsを出せば何でも良いわけではない。
特に旅行系のアプリでは、情報の正確性が重要だ。
たとえば営業時間、料金、交通機関の細かい情報などは変わる可能性がある。
待ち時間の軽いTipsとして表示するには、少し慎重になる必要がある。
そのため、Tipsには次のような内容が向いていると思った。
* 比較的変わりにくい文化的な豆知識
* 旅の楽しみ方に関する一般的なヒント
* 季節や時間帯を考えるときの視点
* 持ち物や移動時の心構え
* 観光地を見るときの観点
逆に、
* 最新の営業時間
* 料金
* 運休情報
* 正確なルート案内
* 予約可否
のような情報は、Tipsとして軽く見せるよりも、必要な場面で正確な情報源と一緒に表示した方がよいと考えている。
また、Tipsは長すぎると読まれない。
待ち時間に表示するなら、1つあたり1〜2文くらいがちょうどよいと感じている。
## 待ち時間用の画面は「待たせる場所」ではなく「期待を作る場所」
今回考えていて思ったのは、待ち時間用の画面は単なる待機画面ではないということだ。
特にAIアプリでは、生成時間はどうしても発生する。
しかもWebアプリでは、その待ち時間が離脱や再読み込みにつながりやすい。
その時間を、
* 仕方なく待ってもらう時間
* ただスピナーを眺める時間
* 不安になりながら待つ時間
にするのか、
* 生成が進んでいると分かる時間
* 結果への期待が高まる時間
* アプリの世界観を感じる時間
にするのかで、ユーザー体験はかなり変わると思う。
Tabideaの場合、テーマは旅行だ。
旅行は、出発してからだけでなく、計画している時間も楽しいものだ。
だからこそ、旅程生成中の待ち時間用の画面も、単なる処理待ちではなく、**旅の準備が進んでいる時間**として見せたいと考えた。
## 今後は写真も組み合わせたい
今後は、生成の待ち時間にTipsだけでなく、**世界各地の写真**や、ユーザーが入力した目的地に関連する写真を表示することも考えている。
旅行では、テキスト情報だけでなく、写真から受ける印象もかなり大きい。
たとえば、旅程生成中にその地域の街並み、自然、建築、食文化などの写真が表示されると、ユーザーは待っている間にも「この場所に行くかもしれない」という想像をしやすくなる。
これは、単なる装飾というよりも、待ち時間を**旅先への期待を高める時間**に変えるための要素だと考えている。
ただし、写真を出す場合も注意点がある。
ユーザーの目的地と関係のない写真を出してしまうと、逆に違和感が出る。また、実際の旅程に含まれないスポットの写真を強く見せすぎると、ユーザーに誤解を与える可能性もある。
そのため、最初は世界各地の汎用的な旅写真を使い、将来的にはユーザーが入力した目的地や生成中の候補エリアに近い写真を表示する形にできると良さそうだ。
Tipsが「読むことで待ち時間を意味のあるものにする」要素だとすれば、写真は「見ることで旅の期待感を高める」要素だ。
この2つを組み合わせることで、待ち時間用の画面をよりTabideaらしい体験にできるのではないかと考えている。
## おわりに
AIを使ったアプリでは、生成時間を完全になくすことは難しい。
もちろん、レスポンスを速くする努力は必要だ。
プロンプトを短くしたり、処理を並列化したり、不要なAPI呼び出しを減らしたりすることは大切だ。
ただ、それでもユーザーに待ってもらう時間は残る。
そのときに、ストリーミングが使えるアプリであれば、少しずつ結果を見せるのはとても有効だ。
一方で、旅行プランナーのように、生成結果を途中で見せるより、チェックしたうえでまとめて表示した方が自然なアプリもある。
そういう場合、生成の待ち時間の体験をどう作るかが重要になる。
私は今回、ゲームのロード画面でTipsが表示される体験から着想を得て、AI旅行プランナーの生成の待ち時間に、プログレスバーと旅のTipsを表示するUIを採用した。
AIの生成の待ち時間は、ただの弱点として扱うこともできる。
でも、アプリのテーマと結びつければ、ユーザーの期待を高める時間にもできるかもしれない。
旅行プランナーにとって、旅は結果画面が表示された瞬間に始まるのではなく、
**生成を待っているその時間から、少しずつ始まっている**のだと思う。
ぜひ**Tabidea**の生成の待ち時間を体験してみてほしい。
https://tabide.ai
---
---
title: "このブログについて:爆速な技術ブログを作り直した理由"
description: "Next.jsからAstroへ。技術選定の過程からAIエージェントとの開発、これからの展望まで、再構築の舞台裏をまとめる。"
lang: ja
url: "https://engineer-blog.tomoki-ttttt.workers.dev/articles/about-this-blog/"
publishedAt: "2026-05-11T15:30:00.000Z"
updatedAt: "2026-05-13T15:00:00.000Z"
tags: ["Astro", "Cloudflare", "技術ブログ", "個人開発"]
projectIds: ["tech-blog"]
---
## 爆速な技術ブログを作り直した理由
このブログ **「ともきちのエンジニア成長記」** は、エンジニアとしての学習・開発・失敗・改善を記録していく場所だ。
実は今回、ゼロからの新規作成ではない。もともとはNext.jsで構築していたが、技術構成から完全に見直して作り直すことにした。それは単なる気まぐれではなく、これまでの個人開発を通じた「反省」と「気づき」の結果だ。
### なぜ今、作り直す必要があったのか
大きな理由は、以前のブログが「技術ブログ」としての本質を見失い、過剰に肥大化していたことだ。
* **機能と演出の盛りすぎ**: 以前の構成では、見た目のインパクトを重視してリッチなアニメーションを多用していた。しかし、情報を探しに来た読者にとって、過剰な演出はかえってノイズになる。「技術ブログは、まず内容が速く届くべきだ」という当たり前のことに立ち返った。
* **保守コストの増大**: React Server Components(RSC)周りのアップデートや脆弱性対応が続き、次第に「記事を書く時間」よりも「ブログのシステムを維持する時間」が増えてしまった。
* **「適材適所」への疑問**: Next.jsはWebアプリ開発には最強のツールだ。しかし、認証もDBもAPIも不要な、更新頻度がそこまで高くない個人ブログに、これほど巨大なフレームワークを使い続ける必要があるのか。その問いに対する答えが、今回のフルリニューアルだ。
これまで旅行ブログの運営で学んだSEOの重要性や、AI旅程生成サービス **Tabidea** で経験したフルスタック開発の知見を活かし、今の自分に最も適した「思考を整理して速く届ける場所」を再定義した。
## 技術選定の舞台裏:なぜAstroだったのか
多くのモダンな選択肢を検討したが、最終的に **Astro** を選んだ。ここに至るまでには、いくつかの葛藤があった。
### 検討した他のフレームワーク
* **Vite + React**: 開発体験は非常に軽快だ。しかし、Markdownのパース、記事一覧の生成、RSS、サイトマップ作成などを一から自作・管理する手間を考えると、コンテンツ配信に特化したAstroに一日の長がある。
* **Remix / TanStack (Router/Start)**: Web標準に寄せた設計や型安全なルーティングはエンジニアとして非常にそそられる選択肢だった。ただ、フォーム処理や動的データ取得を必要としない今回の構成では、宝の持ち腐れになると判断した。
* **Vue (Nuxt) / SvelteKit**: 優れたエコシステムを持っているが、自分の主戦場であるReact/TypeScriptの知見を活かし、開発スピードを最大化するために今回は見送った。
### Astroを選んだ決定打:アイランドアーキテクチャの魅力
Astroは「コンテンツ中心のサイト」を作るために設計されている。ビルド時にMarkdownを純粋なHTMLへ変換し、クライアント側へ不要なJavaScriptを送らない設計は、今回の目的に完璧に合致した。
特に **Astro Content Collections** による型安全な記事管理は、後述するAIエージェントと開発を進める上で、最強のガードレールとして機能してくれる。
## このサイトの技術構成と設計思想
長く、静かに、そして速く運用し続けるために、構成は徹底してシンプルかつモダンにしている。
* **Core**: Astro (React/MDXはあえて非採用)
* **Styling**: Tailwind CSS v4
* **Tooling**: TypeScript, Biome, pnpm
* **Infrastructure**: Cloudflare Workers Static Assets + GitHub Actions
### 徹底した「静的」へのこだわり
「まず静的なHTMLとCSSだけで読めること」を最優先にしている。
* 記事本文はビルド時に完全にHTML化。JavaScriptが無効な環境でも閲覧可能だ。
* クライアント側でのハイドレーションを最小限に抑え、Lighthouseのスコアは常に満点に近い状態を維持する。
* 多言語対応(i18n)についても、複雑なライブラリに頼らず、ディレクトリベースのシンプルな設計を採用した。
## AIエージェントと共創する開発プロセス
今回のブログ構築において、特筆すべきは開発スタイルだ。私は現在、Claude Code、Codex、Gemini CLIなどのコーディングエージェントを片時も離さず開発している。
AIに実装を任せる時代だからこそ、人間側が **「設計思想と制約」** を明文化しておくことが重要になる。
このサイトではあえて以下の制約を設けている。
1. **「Reactを導入しない」**
2. **「記事をクライアント側で動的に描画しない」**
3. **「不要な外部スクリプトを増やさない」**
これらの制約をプロンプトやドキュメントに焼き付けておくことで、AIが良かれと思って構成を複雑化させるのを防ぎ、長期間メンテナンス可能なコード品質を維持している。AIは実装を速めてくれるが、サイトの「純度」を守るのは人間の役割だ。
## 多言語対応:日本語と英語で発信する理由
このブログは、 `/en/` 配下で英語版も展開する。これには、単なる露出増加以上の意図がある。
技術情報の一次ソースは常に英語だ。将来的に自分の考えや実装方針を英語圏のエンジニアにも伝えられるようにしておくことは、キャリアの選択肢を広げるだけでなく、思考の解像度を上げることにも繋がる。日本語で考えたことを、英語でもう一度再定義する。そのプロセス自体を、自分の成長の一部にしたいと考えている。
## これから書いていくこと
ここでは、完成された「正解」だけを載せるつもりはない。むしろ、実際の開発現場で直面する泥臭い試行錯誤を言語化していく。
* **個人開発の設計と思考**: Tabidea開発で直面した技術的負債との戦いや、設計の意思決定。
* **AIエージェントとの協調**: どのようにAIに指示を出し、レビューし、プロダクトを形にしていくかという実践論。
* **インフラと運用**: Cloud RunやCloudflareなど、低コストかつ堅牢なシステムを構築するためのTips。
* **フロントエンドのUX**: 流行りに流されず、「使いやすさ」と「速さ」を両立させるための実装。
* **セキュリティと保守**: 個人開発で見落とされがちな認証や機密情報管理のベストプラクティス。
## おわりに
このブログは、一度立ち止まって「自分にとって本当に必要なツールは何か」を問い直した結果だ。
最初から大きなメディアを目指すのではなく、日々の開発で得た小さな手応えや、痛烈な失敗を、静かに、しかし情熱を持って積み上げていく。数年後に読み返したとき、エンジニアとしての成長が一本の線として見えるような、そんな場所に育てていきたい。
速く、軽く、読みやすく。
===
# Tomokichi's Engineering Growth Log — Articles (English)
---
title: "Before Calling It \"AI Optimization\": Why I Added llms.txt and WebMCP to My Engineering Blog"
description: "There is no settled answer yet for what an LLM- or agent-friendly web should look like, so I added llms.txt, llms-full.txt, and WebMCP to this blog as an experiment. Less a bet on future ranking gains than a small test of giving non-human readers an easier way in — here is what I built and why."
lang: en
url: "https://engineer-blog.tomoki-ttttt.workers.dev/en/articles/llms-txt-webmcp/"
publishedAt: "2026-06-07T14:00:00.000Z"
tags: ["AI", "WebMCP", "llms.txt", "Astro", "Engineering Blog"]
projectIds: ["tech-blog"]
---
Lately, across search, browsers, and developer tools, there are more and more moments where AI reads information from a website or acts on a user's behalf.
Along with that, terms like "LLMO," "AI search optimization," and "AI agent support" have started showing up everywhere.
But right now, I don't think it's settled what you should implement to be valued by AI, which specifications will see wide adoption, or whether there's even a shared, correct answer that deserves to be called "optimization."
So when I added `llms.txt`, `llms-full.txt`, and WebMCP to this blog, it wasn't because I believed "adding this makes you strong in AI search."
It was because **as the chance that non-humans read a website grows, I wanted to provide an entry point — separate from the HTML — that makes the content easy to hand to a machine.**
This post walks through what I implemented, and why I tried these mechanisms first, even though their effect and their future are far from decided.
## The human-facing screen and the content itself can be considered separately
A normal web page is built on the assumption that a human reads it in a browser.
Beyond the article body, it includes a header, navigation, a table of contents, share buttons, related articles, a profile, a footer, and many other elements.
Those are UI a human needs, but for a program or AI that only wants the article's content, not all of them are necessarily required.
Of course, today's AI can extract the body from HTML. So it isn't that the content can't be read unless I provide a Markdown version.
Still, handing over Markdown that already contains the title, metadata, and body from the start is simpler than making something parse the HTML and guess "where does the body begin and end."
This blog's articles are managed in Markdown to begin with.
So generating HTML for humans while also returning Markdown or JSON close to the original structure when needed was relatively natural to implement.
Rather than rebuilding the human-facing UI into something for AI, it's closer to the idea of **providing multiple representations of the same content.**
## What I implemented this time
The main additions were these three.
* `llms.txt`, a guide to the whole site
* `llms-full.txt`, which bundles every article
* A WebMCP tool that returns the Markdown of the article currently open
Each plays a slightly different role.
## llms.txt is less an AI-only sitemap than a "guide board"
On this blog, I placed [`/llms.txt`](/llms.txt) at the root.
In addition to the site name and description, it includes the following.
* A list of Japanese articles
* A list of English articles
* The Markdown-version URL of each article
* Main pages such as Home, About, Works, and Archive
* The Japanese and English RSS feeds
* A link to `llms-full.txt`
The mental image is less a sitemap that mechanically lists every URL on the site, and more a **guide board that shows what exists here and which information is a good place to start.**
```txt
# Tomokichi's Engineer Growth Log
> A personal engineering blog for logging learning, building, failures, and improvements.
## Articles (Japanese)
- [Article title](https://example.com/articles/example.md): Article description
## Pages
- [About](https://example.com/about/): Author profile and focus areas.
```
If I updated the article list by hand, I might forget to update `llms.txt` every time I publish.
So I fetch published articles from Astro's Content Collections and generate the list at build time.
```ts
const articles = await getArticlesWithPaths(locale);
return articles
.map(
({ article, slug }) =>
`- [${article.data.title}](${articleMarkdownUrl(locale, slug)}): ${article.data.description}`,
)
.join("\n");
```
The article titles and descriptions reference the same frontmatter as the normal article pages.
That way, instead of separately maintaining information just for AI, it's **generated from the same source as the human-facing pages.**
That said, `llms.txt` is one of the formats currently being proposed, and it doesn't guarantee that every AI service will read it.
At least for now, rather than "a file that increases AI traffic just by placing it," I think of it as an optional entry point a machine can use when it wants to grasp the site's structure.
## llms-full.txt bundles the Markdown of published articles
[`/llms-full.txt`](/llms-full.txt) concatenates the published Japanese and English articles as raw Markdown.
```ts
const body = articles
.map(({ article, slug }) => buildArticleMarkdown(article, locale, slug).trim())
.join("\n\n---\n\n");
```
If `llms.txt` is an index of links to articles, then `llms-full.txt` is the file that bundles the bodies as well.
This format isn't strictly necessary.
In fact, as the number of articles grows, the file could become too large to handle comfortably. In the future, splitting it by category or language might be better.
Even so, at the current stage where there aren't many articles yet, it's usable when you want to check the whole site's content at once.
Here too, the goal isn't to raise search rankings, but to **add one more form that's easy for whoever needs it to handle.**
## Each article is also available as Markdown and JSON
So that `llms.txt` can reference them, each article has a Markdown version separate from the HTML one.
For example, if a normal article URL looks like this,
```txt
/articles/example/
```
the Markdown version can be retrieved at this URL.
```txt
/articles/example.md
```
The article page's `head` also lists the Markdown and JSON versions as alternate representations.
```html
```
This isn't a feature only for AI.
It's also useful for people who want to copy the Markdown directly, who want to load it into another tool, or for programs that want to handle the metadata as JSON.
Rather than closing it off as an "AI feature," I think it's more natural to see it as **a general output format that makes content easier to reuse.**
## With WebMCP, I exposed only a tool to fetch the current article
WebMCP is a draft specification for a web page to expose structured tools in the browser so that a supporting AI agent can call them.
On this blog, when an article page opens, I register a tool called `get_current_article_markdown`.
Its only role is to return the Markdown of the article currently displayed.
Simplified, the implementation looks like this.
```ts
const modelContext = document.modelContext;
modelContext.registerTool({
name: "get_current_article_markdown",
description: "Return the full Markdown source of the article currently open in this tab.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
annotations: {
readOnlyHint: true,
},
async execute() {
const response = await fetch(markdownUrl);
const text = await response.text();
return {
content: [{ type: "text", text }],
};
},
});
```
Exposing multiple tools for search, posting, or editing is conceivable, but this time I didn't go that far.
For now, I implemented only a low-impact tool that:
* requires no input
* changes no state on the server
* handles no personal information
* only reads an already-published article
I also set `readOnlyHint: true` to indicate that this tool is read-only.
And in browsers where the WebMCP API doesn't exist, it simply does nothing and exits.
```ts
if (!modelContext || !root) return;
```
So even in unsupported environments, normal article reading is unaffected.
To keep an old article's tool from lingering across Astro's page transitions, I also deregister it with an `AbortController` before leaving the page, and register it again on the next page.
In this way, I don't make WebMCP a premise of the site, but treat it as **a progressive feature that's added only in environments where it can be used.**
## Why implement it when adoption isn't decided yet
Honestly, I don't know how far `llms.txt` or WebMCP will spread from here.
The specifications might change, and another approach might become the mainstream.
Even so, there were roughly four reasons I implemented them.
### 1. Because web users aren't necessarily only humans anymore
In web development so far, it was mostly enough to think about humans viewing pages in a browser.
But now, beyond search engines, AI assistants, browser agents, and developer tools also retrieve information on the web more and more.
I can't assert what the future shape will be, but I thought it was worth considering whether it's enough for a website to offer only a human-facing screen.
### 2. Because the source data is Markdown, so I could try it at low cost
This blog is structured to convert Markdown into HTML with Astro.
So there was no need to rebuild the content from scratch for machines.
From the same Markdown I can generate HTML, Markdown, JSON, and `llms.txt`, and WebMCP can return that Markdown too.
Being able to try this using the existing content model, without adding a large system, suited a personal blog well.
### 3. Because I'd rather implement and observe than predict the effect
When thinking about a new technology, one option is to respond after it becomes popular.
On the other hand, if it can be implemented in a small way, there's often more to learn from actually touching it.
* What kind of information should be structured
* How to avoid duplicating information with human-facing pages
* Whether the design can stay easy to follow as the spec changes
* How to separate read-only from state-changing operations
These are easier to think about concretely once you've implemented it than by only reading about it.
This effort is less a bet on the future than **a small experiment for understanding the change.**
### 4. Because I could make it easy to remove later
When trying a new specification, I think ease of removal matters as much as ease of adoption.
This implementation doesn't replace the existing article display or URL structure.
`llms.txt` and `llms-full.txt` are additional outputs, and WebMCP only runs when the API exists.
Even if it ends up unused, the impact on normal blog functionality is small.
Precisely because it's a technology whose adoption I can't read, I tried it as a loosely coupled add-on rather than placing it at the center.
## This isn't a story about "LLMO is now handled"
Calling this implementation "LLMO measures" or "AI search optimization" feels slightly off to me.
The very scope that "LLMO" refers to isn't fixed, and how AI services discover, retrieve, and use a website in their answers differs from service to service.
Placing `llms.txt` doesn't guarantee that AI will cite you in its answers.
Implementing WebMCP doesn't mean common browsers or AI agents can use it right away either.
At the moment, neither promises a widely established result.
So on this blog, rather than "optimized for AI," I think it's more accurate to say
**I experimentally prepared a machine-readable path that AI or programs can use when retrieving the content.**
## llms.txt is not a substitute for robots.txt or a usage license
Another thing I want to be careful about is that `llms.txt` is not an access-control mechanism.
It differs in role both from something like `robots.txt` that tells crawlers an access policy, and from a license that defines content copyright or terms for AI-training use.
This blog's `llms.txt` is there to guide what the site contains and which formats are easy to retrieve.
Questions of what may be crawled and how articles may be used need to be considered separately through robots.txt, terms of use, licenses, and each service's behavior.
By its name alone it might look like a file that states every AI-related policy, but at least in this implementation I treat it as **a guide to already-published information, not something that grants permissions.**
## If WebMCP changes state, the story changes a lot
The WebMCP tool I exposed this time only returns an article's Markdown, so what it can do is limited.
But if WebMCP handles form submission, purchases, reservations, posting, or settings changes, simply registering a tool is not enough.
Just like a normal web UI, or even more so, you need to think about:
* authentication and authorization
* input validation
* protection against CSRF and malicious requests
* confirmation for important operations
* preventing double execution
* rate limiting
* operation logging
* keeping the tool's description and its actual behavior in agreement
Just because an AI agent is the caller doesn't make it a trusted client.
This time I limited it to a feature that only reads public content. Even if I add tools later, I want to avoid exposing state changes just because they seem convenient, and instead consider the boundaries the same way I would in normal API design.
## What I want to check going forward
At the point of implementing it, this isn't "done" yet.
Going forward, I want to check things like:
* whether access to `llms.txt` or the Markdown versions actually happens
* whether `llms-full.txt` becomes too large as the number of articles grows
* whether I can follow WebMCP spec changes without strain
* how far support spreads on the browser and agent side
* whether the machine-facing explanations contradict the human-facing ones
That said, even if there are records in the access logs, I can't necessarily tell precisely whether the use is by AI, or just a check or a crawler.
Even if I can get numbers, I think I need to observe over the long term rather than immediately tying them to an effect.
## Closing
This time, I implemented `llms.txt`, `llms-full.txt`, and WebMCP on this engineering blog.
But this isn't an assertion that "from now on, this approach will be the correct answer."
The shape of AI search and an agent-friendly web is still in the middle of changing.
That's exactly why, instead of deciding the future and building heavily, I added an entry point that makes information easier to hand to machines as well, within a range that doesn't break the existing human-facing experience.
Some people read HTML.
Some people want to use Markdown directly.
Sometimes a program retrieves JSON.
And in the future, an AI agent in the browser might use WebMCP's tools.
I can't predict all of that from now.
Even so, I think a design that separates content from presentation and can safely offer the same information in multiple forms has meaning regardless of the AI trend.
This implementation isn't a tactic for conquering an unknown ranking.
**It's a small experiment for thinking about a web where humans aren't necessarily the only users.**
## References
* [The /llms.txt file](https://llmstxt.org/)
* [WebMCP Draft Community Group Report](https://webmachinelearning.github.io/webmcp/)
* [WebMCP is available for early preview](https://developer.chrome.com/blog/webmcp-epp)
---
---
title: "Security Design to Consider When Building a Login-less App"
description: "Removing login means replacing identity verification and permission management with other mechanisms. Using Nobo Page as an example, this note organizes design topics like authorization, sessions, CSRF, share URLs, tokens, and XSS."
lang: en
url: "https://engineer-blog.tomoki-ttttt.workers.dev/en/articles/login-less-app-security-design/"
publishedAt: "2026-06-05T12:00:00.000Z"
updatedAt: "2026-06-07T18:00:00.000Z"
tags: ["Security", "Web", "Design", "Nobo Page"]
projectIds: ["nobo-page"]
---
## Introduction
An app you can use without logging in is convenient.
There's no need to enter an email address or set a password — you open the page and start using it right away. It fits especially well with services not meant for long-term use: temporary notes, event announcements, simple shared boards, and so on.
At the same time, removing the login screen doesn't necessarily simplify the design.
A login feature isn't just for displaying a username; it also plays roles like these.
- Confirming who created the data (authentication)
- Deciding who may view, edit, or delete it (authorization)
- Recovering lost permissions
- Identifying unauthorized actions
Here, "authentication" and "authorization" are distinct concepts. Authentication is verifying the identity of a user, process, or device; authorization is granting or checking access rights to a resource. When you remove login, you have to cover these roles with another mechanism or restrict the features themselves.
This post organizes what's worth considering when designing a login-less app, using **Nobo Page** — which I'm currently considering building — as an example.
## Even without login, you still need "authorization"
In an app with login, you can decide permissions based on the user's account.
For example: "this user is the creator of this data, so they may edit it." Here you use the result of account authentication to perform authorization.
Without login, you can't verify identity (authenticate) via an account. What you can confirm is usually not "is this person really the creator," but rather "did they present the correct edit token" or "do they hold the same session identifier as at creation time."
In other words, what a login-less app actually does is closer to **confirming possession of a secret that proves a permission**, rather than verifying a human's identity. Common means include:
- the browser session
- cookies
- a randomly issued token
- dedicated view / edit URLs
- identifying information stored on the device
A URL that functions as a permission in this way is generally called a **capability URL**. Rather than "removing login," it's closer to replacing account authentication with authorization based on possession of a secret.
As an aside, while the term and idea of a capability URL are sound, the W3C document people often cite is a First Public Working Draft from 2014 — not a W3C Recommendation. That's worth keeping in mind.
## If you don't share, the design can be quite simple
Even a login-less app needs far less consideration if the data isn't shared with others.
For example, a design where data can only be viewed from the session of the browser that created it.
```text
Create data
↓
Save to server
↓
Retrievable only from the creating session
```
In this case there's no need to put a permission in the URL.
If you manage the session with a secure cookie, you can more easily avoid the problem of a leaked URL exposing the data itself. That said, "only the creating browser can retrieve it" is a convenient simplification — what can actually access it is **any client that can present that session cookie**. If the session identifier is copied or stolen, it can be used from another browser too.
So if you use cookies, at minimum keep these in mind.
- Set `Secure`, `HttpOnly`, and an appropriate `SameSite` on the cookie
- Generate the session identifier with a cryptographically secure RNG, and make it long enough (128 bits or more as a guideline)
- Don't leave the lifetime to the cookie's Max-Age alone; manage the session's lifetime on the server too
### If you use cookies, don't forget CSRF
A common blind spot with cookie-based sessions is **CSRF (cross-site request forgery)**.
The browser automatically attaches cookies to requests aimed at the target site. So merely opening an attacker's page can cause the user to send unintended edit or delete requests. For state-changing requests, combine measures like these.
- Don't perform side effects such as edit/delete via GET
- Use your framework's CSRF protection or CSRF tokens
- Also validate the `Origin` header and use Fetch Metadata
- Set the `SameSite` attribute appropriately
`SameSite` suppresses many CSRF cases, but it isn't a single, complete defense in every configuration. As long as you use cookies, CSRF needs separate thought.
None of this makes XSS go away, either. If malicious JavaScript runs inside the page, it can send requests to the API using the current session even without reading the cookie value directly. A session-only design reduces risk, but it doesn't remove the need for XSS defenses.
## The hard part is "sharing without login"
A login-less app's design gets especially complex when you let created data be shared with others.
If the recipient also has no account, the server can't use an account to decide "is this person authorized to view it."
So one approach is to issue a URL containing a random string and treat possession of that URL as the permission.
```text
Holds the view URL → can view
Holds the edit URL → can edit
Holds the admin URL → can delete or change settings
```
It's a handy mechanism, but this URL isn't just a page's address. Holding the URL is close to a state of knowing a password.
## "Only people who know the link" isn't necessarily private
Services that use share URLs often explain it as "only people who know the link can view it."
But this phrasing needs a little care.
Whoever receives the link can forward it to someone else. It remains in chat history and email, and it can appear in screen shares or screenshots. Fully recalling a once-leaked link isn't easy either.
So it's risky to flatly claim "it's safely private" for a service using share URLs. More accurately, the situation is:
> Anyone who knows this link can access it.
You also need to clearly explain to users that it isn't suited for storing confidential information or sensitive personal data.
## Separate view, edit, and admin permissions
Issuing a single share link that lets its holder view, edit, and delete is simple. But the damage when the link leaks is also large.
So one approach is to separate permissions by purpose.
```text
View link
Edit link
Admin link
```
The view link permits only viewing; the edit link permits changing content. High-impact operations like deletion or changing the retention period are limited to the admin link.
For Nobo Page too, if it offers sharing, separating permissions like this is a candidate design. With permissions separated, even if a view link is shared widely, you avoid also granting edit or delete. This is exactly the principle of least privilege.
## Don't store permission tokens as-is in the DB
The random token in a share URL is a secret that grants an operation simply by being presented. So you want to avoid storing the token as-is in the database.
```text
Share URL:
https://example.com/page/123#edit=abc123...
DB:
edit_token = abc123...
```
With this, anyone who can read the database — or anyone who obtains the data through a leak — could use the edit permission directly.
Instead, store only a digest (hash) of the token on the server.
```text
What the user holds:
edit_token = a random value of 128+ bits from a CSPRNG
What the DB stores:
edit_token_digest = HMAC-SHA-256(server_secret, edit_token)
```
What's worth noting here is that **this differs in purpose from password hashing.**
Because a password a user memorizes is easy to guess, it needs a deliberately slow hash like Argon2id or bcrypt. A sufficiently long token generated by a cryptographically secure RNG, on the other hand, is inherently hard to target with dictionary or brute-force attacks. So a costly password hash isn't mandatory for tokens; securing entropy matters more.
Even a plain `SHA-256(token)` provides brute-force resistance for a high-entropy token. Using `HMAC-SHA-256` with a server-side secret makes it harder for an attacker who only obtained the DB — and not the secret key — to compute the same digest.
For implementation, also keep these in mind.
- Use a cryptographically secure random generator (CSPRNG)
- Bind each token to its permission and expiry on the server side
- Make tokens revocable and rotatable
- For comparison, use a safe (timing-resistant) comparison function from an existing library
- Don't emit tokens to access logs, analytics platforms, or error bodies
So it isn't that "hashing makes it safe"; this is accurately understood as **a measure to mitigate the damage when the DB alone leaks.** Because the actual token exists in the user's browser, browser-side safety remains important too.
## Why use a URL fragment, and what comes after
When putting a share token in a URL, one option is to use a URL fragment rather than a query parameter.
```text
https://example.com/page/123#edit=secret-token
```
The part after `#` is normally not sent to the server in an ordinary HTTP request, and it isn't included in the `Referer` header either. So it's easier to avoid the token ending up as-is in CDN or web server access logs.
However, for the server to verify the edit permission, JavaScript must ultimately send the token to the server. Putting it in the fragment doesn't complete the story. Typically the flow is:
```text
Open /page/123#edit=secret-token
↓
JavaScript reads location.hash
↓
Sends it via the POST body or the Authorization header
↓
The server verifies it
↓
history.replaceState() removes the token from the URL
```
You need to make sure the token you sent isn't recorded into APM, a WAF, application logs, or error reports. And after verification, clear the token from the address bar and history with something like `history.replaceState()`, so it isn't re-exposed by sharing or the back button.
To lean toward the safer side, instead of sending the raw edit token from JavaScript every time, this composition is also a candidate.
1. Hand the fragment token to the server once to exchange it
2. Issue a short-lived session scoped to that operation
3. Use that session via an `HttpOnly` cookie thereafter
4. Remove the original token from the URL
But since this composition uses cookies, the CSRF measures above are needed as a set. A URL fragment is, at most, one means of keeping the token out of server logs — it doesn't make the token itself safe.
## Sharing amplifies the impact of XSS
XSS is a vulnerability where strings entered by a user aren't handled safely and get executed as HTML or JavaScript. In a login-less sharing app, it needs especially careful thought.
For example, suppose an attacker manages to save content like this to a board.
```html
```
If this content is displayed as raw HTML, the script may run in the browser of another user who opens the share link.
In a non-sharing, session-only app, the path to making an ordinary other user trip over saved content is narrower. But that doesn't make Stored XSS disappear. Saved content can reach other people's eyes on screens like these.
- The operator's admin / report-review screen
- Support handling screens
- Preview or export features
- Screens used for incident investigation
- Sharing features added in the future
If any of these display the saved content, it can still be Stored XSS against another person. Moreover, Reflected XSS and DOM-based XSS occur regardless of whether sharing exists.
Main defenses include:
- Don't render user input directly as HTML
- Apply context-appropriate output encoding (HTML escaping) thoroughly
- Use safe DOM APIs and avoid raw assignment to `innerHTML`
- Reject dangerous URL schemes (such as `javascript:`)
- Don't allow arbitrary scripts or embedded code
Content Security Policy (CSP) is also useful, but it's accurately positioned as one layer of **defense in depth**, not the primary defense. The basics are safe DOM APIs, context-appropriate output encoding, and HTML sanitization only where needed.
When handling Markdown, rather than "sanitize the Markdown input and then turn it into HTML," it's safer to **convert the Markdown to HTML and then pass that HTML through a trusted sanitizer**, because dangerous HTML can be produced during conversion.
## HttpOnly cookies alone don't prevent XSS
Using HttpOnly cookies for session management is important. Setting HttpOnly prevents JavaScript from reading the cookie directly via `document.cookie`.
However, when XSS occurs, the attacker's script runs on the same page as the user. So even without obtaining the cookie itself, it can make the browser auto-send the cookie and call the API.
```text
Steal the cookie
→ easier to prevent with HttpOnly
Operate the API with the current session
→ still possible if XSS exists
```
HttpOnly cookies are a measure against exfiltrating session information. They don't prevent reading the screen or performing unauthorized operations via XSS.
## Don't leave too much permission info in the browser
In a login-less app, you may be tempted to store permission info in the browser so the user can edit the same data again — for example, saving the edit token in LocalStorage.
But LocalStorage can be read by JavaScript on the page. If XSS occurs, the saved edit or admin token could be sent to an external party. The same goes for sessionStorage and IndexedDB; they are equally weak against XSS. Also, when using a shared PC or someone else's device, the permission info keeps lingering on that device.
So you need to decide carefully what to store in the browser. Information like language or theme settings should be considered separately from edit or admin permissions.
For Nobo Page, rather than storing permissions long-term for convenience, a design where users keep the admin link themselves as needed leans toward the safer side.
## Don't let confidential responses be cached
View / edit screens and API responses that carry tokens should specify cache control explicitly depending on their content.
With nothing specified, confidential responses may remain in the browser, a CDN, a proxy, a Service Worker, and so on. This can even lead to incidents where a previous user's content is shown on a shared device.
- Consider `Cache-Control: no-store` for confidential responses
- Note that `no-cache` doesn't mean "don't store"; it means "revalidate before use"
- Keep URLs and responses containing tokens out of intermediate caches
## Don't perform deletion or permission changes via GET
Avoid a design where merely opening a capability URL performs deletion, publishing, or permission changes.
Link-preview generation, search-engine crawlers, and security scanners may access the URL automatically. If they trip over a URL with side effects, unintended deletion or changes can happen.
Always perform state-changing operations through explicit requests like POST, combined with the CSRF measures above. The principle is to give "opening a URL" no destructive side effects.
## Prepare for abuse unique to anonymous access
Without login, it's hard to grasp who is using how much on a per-user basis. So prepare for abuse unique to anonymous services.
- Spam posts and mass automatic board creation
- Abuse aimed at consuming storage
- Creating and distributing phishing pages
For each create / update / view API, providing rate limiting that doesn't rely on IP alone, size and count limits, anomaly detection, and means to report and revoke leans toward the safer side.
If you offer file attachments in the future, you'll separately need type and size restrictions, storage outside the web root, virus scanning, download controls, and so on.
## Don't assume things can be recovered
In a service with login, you can verify identity with an email address and recover an account or data.
Without login, there's no way to confirm whether a user who lost the admin link is really the creator. Even if someone inquires "I'm the one who made this board," reissuing admin permission without proof is dangerous.
So in exchange for the lightness of being login-less, constraints like these are needed.
- Losing the admin link means it can't be recovered
- Deleted data, in principle, can't be brought back
- Data past its retention period can't be recovered
- Permissions aren't reissued on an inquiry alone
Also, if you explain "it auto-deletes" or "it can't be recovered," the scope should match reality. Even if you delete the primary data, copies remaining in places like these would contradict the explanation.
- Backups
- CDN caches
- Search indexes
- Thumbnails and converted files
- Audit logs and incident-analysis data
It looks inconvenient, but it's also a restriction needed to avoid handing data to third parties.
## Separate external scripts by "origin," not just by "screen"
Web services accumulate various external scripts: analytics, ads, heatmaps, chat support, and more. An external script runs as JavaScript with the same privileges as the page it's embedded in, so it brings risks like arbitrary code execution, leaking confidential information, and compromise of the provider.
So rather than putting the same script on every page, there's a way of thinking that separates by the screen's role.
But merely "not placing it on the board screen" can be insufficient isolation as long as both share the same origin. To separate more strongly, separate the origin itself.
```text
www.example.com
→ intro / ads / analytics
app.example.com
→ board viewing / editing
→ external scripts disallowed in principle
```
Web Storage and IndexedDB are isolated per origin, so a separate origin also isolates client-side data. Along with that, it's important not to carelessly set `Domain=.example.com` on cookies, and to limit them to the necessary origin.
## For Nobo Page, keep the value of sharing
Remove sharing and make it a session-only app, and Nobo Page's design can be quite simple. There's no need to put permissions in the URL, and no need to separate view, edit, and admin links.
But for Nobo Page, being able to hand created content to others right away is one of its values. Day-of event guidance, short-lived shared notes, info pages opened from a QR code — these don't fulfill their purpose if only the creator can view them.
So rather than removing sharing itself, you need to design on the premise of the risks that sharing adds. Concretely, a policy like this.
- Separate view, edit, and admin permissions (least privilege)
- Issue tokens of 128+ bits from a CSPRNG
- Don't store raw permission tokens in the DB; keep digests instead
- Give permission links an expiry and a revocation method
- If using cookies, set `Secure` / `HttpOnly` / `SameSite` and CSRF measures
- Display user input safely (output encoding and HTML sanitization)
- Don't let confidential responses be cached
- Don't perform state changes via GET
- Provide rate limiting and abuse measures for anonymous use
- Restrict external scripts on the board screen, ideally on a separate origin
- Don't make the retention period longer than necessary
- Clearly communicate that it isn't for confidential information
Being login-less doesn't make something dangerous on its own. But since you remove the authentication and authorization that login provided, you must not leave the replacement mechanism vague.
## Conclusion
A login-less app greatly reduces the burden before someone can start using it. It fits especially well with temporary uses and situations that don't warrant creating an account.
On the other hand, when you remove login, you have to think about the authentication and authorization the account provided in another way. A non-sharing, session-only app can have a relatively simple structure — but as long as it uses cookies, you still need to think about CSRF and session management. When you share data with others without login, the URL and token themselves become the permission.
In that case, these points matter.
- Treat the URL as a key, not just an address
- Don't make permissions broader than necessary
- Generate and store tokens safely
- Prevent permission leakage and unauthorized operations via XSS and CSRF
- Limit caching, retention period, and recovery scope
- Honestly communicate the service's limits to users
The ease of being login-less doesn't mean you don't have to think about security. If anything, to let users use it safely without being aware of it, the backend needs a design different from a normal login app.
## References
- [Authentication — NIST Computer Security Resource Center Glossary](https://csrc.nist.gov/glossary/term/authentication)
- [Good Practices for Capability URLs (W3C Working Draft)](https://www.w3.org/TR/capability-urls/)
- [Cross-Site Request Forgery Prevention — OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [Session Management — OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
- [Cross Site Scripting Prevention — OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
- [Same-origin policy — MDN](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Same-origin_policy)
---
---
title: "Why I'm Turning My Personal Blog into a Portfolio in the AI Era"
description: "In an age where AI can build almost anything, it's becoming more important to show 'how you think' rather than just 'what you built.' I'll explain why I'm growing my personal blog as a portfolio."
lang: en
url: "https://engineer-blog.tomoki-ttttt.workers.dev/en/articles/ai-era-portfolio-blog/"
publishedAt: "2026-05-28T23:00:00.000Z"
tags: ["Portfolio", "IndieDev", "AI", "Blogging"]
projectIds: ["tech-blog"]
---
Recently, I've been thinking about growing my personal blog into a form that can also serve as a portfolio.
When you hear the term "portfolio site," you might imagine a place where you list your projects, mention the technologies you can use, and post a self-introduction. Those are certainly important.
However, I feel that the meaning of creating a portfolio site is shifting in today's world.
With AI, the visual aspects of websites and applications can now be created quite easily.
Stylish designs, lengthy code, self-introduction pages—these things can be made in much less time than before.
Because of this, a portfolio that simply shows "I can build this" might carry less weight than it used to.
Nevertheless, I am choosing to turn my personal blog into a portfolio.
The reason is that I believe what will be important from now on is not just *what* you built, but **with what intent, how you thought about it, and how you built it.**
## Intent Matters More Because We Can Build with AI
AI has significantly lowered the hurdle to giving ideas a physical form.
Generating ideas.
Creating UI drafts.
Writing code.
Polishing prose.
Finding the cause of errors.
Many of these tasks can be accelerated considerably with the help of AI.
But that doesn't mean the "value of the person making it" disappears.
On the contrary, now that almost anyone can build something to a certain level, the next questions asked will be:
* Why are you building it?
* Why did you choose that technology?
* Why that specific UI?
* What did you prioritize, and what did you discard?
* Which parts did you leave to AI, and where did you make your own judgments?
* How will you improve it after it's built?
Looking only at the finished product, it's hard to tell whether it was made by AI or how much a human thought about it.
However, when the decision-making and trial-and-error behind it are visible, the developer's way of thinking becomes clear.
The portfolio I want to create is not just a collection of works, but a place like a **log of decision-making.**
## I Want to Convey "Thinking and Building," Not Just "Building"
I believe that people looking at a portfolio want to know more than just how the final product looks.
Of course, the visual quality and technical implementation skills are important.
But in actual development, being able to explain "why I did it that way" is often more crucial.
For example, do you choose a static configuration focusing on performance?
Do you prioritize a rich experience and accept some weight?
How carefully do you handle accessibility and semantic HTML?
Even when using AI, how much do you delegate, and where do you take responsibility yourself?
These judgments reveal a person's values.
I don't want to just say, "I can use Next.js," "I can write React," or "I can develop with AI."
I want to convey **what I value and what kind of judgments I make while building.**
To achieve this, I felt that a blog format was more suitable than a traditional portfolio site.
## A Personal Blog Can Preserve Parts Invisible in a Finished Product
The beauty of a personal blog is that you can record not only the finished product but also your thoughts along the way.
For example:
* Why did you build that site?
* What was the initial design?
* What didn't go well during implementation?
* What did you improve?
* Which technology choices were you torn between?
* How did you use AI?
* How do you want to grow it in the future?
You can leave these things as articles.
Background information like this doesn't come across well through GitHub repositories or finished websites alone.
However, by verbalizing them in articles, people can see your way of thinking and your growth process.
This is meaningful not only for job hunting or showing to companies but also for yourself.
What was I thinking in the past?
What did I struggle with, and how did I decide?
Where have I grown, and what are my remaining challenges?
By keeping such records, I believe the core of my identity as a developer will gradually become clearer.
## Showing the Human Side of Judgment precisely Because We Use AI
I don't think there's any need to hide the fact that I use AI.
In fact, being able to use AI effectively should be one of the important skills in future development.
However, there is a difference between using AI and leaving everything to AI without thinking.
Even if you have AI write code, you are the one who ultimately decides whether to adopt that code.
Even if you use a UI draft from AI, you are the one who considers whether it's truly a good experience for the user.
Even if you have AI polish your writing, you must confirm for yourself whether your own thoughts are there.
That's why, in an AI-era portfolio, I believe it's important to show not just the finished product, but **how you face AI and where you place your own judgment.**
I want to make it a place where people don't just say, "You just made it with AI, right?" but rather, "While using AI, you designed it with this intent and improved it with these judgments."
## My Portfolio is a Growth Record Rather Than a Collection of Works
In turning my personal blog into a portfolio, I don't want to just add a profile page or a project results page.
Of course, I will organize my projects and the technologies I use clearly.
But beyond that, for each project, I want to record:
* What kind of problem-solving awareness led to its creation?
* What were the specific points of focus?
* What did I try technically?
* What did I fail at?
* How do I want to improve it in the future?
Instead of just neatly lining up finished products, I want to show the process of them growing, including things that are still in progress.
By doing so, I believe I can convey not just my current ability, but **how I learn, how I improve, and how I grow as a person.**
## What I Want to Show on This Blog
On this blog, I plan to gradually organize the websites and apps I am building.
For example, a travel blog I run personally, an AI-powered travel planning app, improvements to this technical blog itself, performance tuning, accessibility, UI design, and development flows using AI.
I want to write not just introductions to my work, but also the thoughts that went into them, the struggles I had during implementation, and the improvements I made.
I am particularly interested in Web experience, performance, pleasant UI, and growing indie development continuously.
Such interests and values are hard to convey through a list of skills alone.
That's why I want to use this blog to convey what kind of things I want to make and from what perspective I approach Web development.
## Conclusion
I think making things will become even easier with AI.
That's why it becomes important to show **what you think, what you choose, and how you improve**, rather than just showing that you can build.
For me, turning my personal blog into a portfolio is an initiative for that purpose.
I will make it a place that preserves the way of thinking, trial and error, and the process of growth, rather than just a place to line up finished products.
I want to grow this blog into such a portfolio, little by little.
---
---
title: "Why I Added Syntax Highlighting with Shiki to My Fast Tech Blog"
description: "A short note on why I introduced syntax highlighting with Shiki to my Astro-based tech blog, and how I balanced performance, readability, and maintainability."
lang: en
url: "https://engineer-blog.tomoki-ttttt.workers.dev/en/articles/add-shiki-syntax-highlight/"
publishedAt: "2026-05-21T16:30:00.000Z"
tags: ["Astro", "Shiki", "Web", "Performance", "Tech Blog"]
---
## Introduction
This blog is built with a focus on being lightweight, fast, and easy to read.
The basic policy is simple: render article content as static HTML and avoid unnecessary JavaScript during the initial page load. Whenever I add visual details or features, I try to ask whether they are truly necessary for the reading experience.
With that in mind, I decided to introduce syntax highlighting using Shiki.
## Why I Added Syntax Highlighting
At first, I was not sure whether syntax highlighting was necessary.
Since this blog is designed to be fast, I did not want to add a library just because it seemed convenient. For short code snippets, plain text can be enough.
However, code and configuration files are unavoidable in a tech blog. Articles about Astro, TypeScript, CSS, Cloudflare Workers, GitHub Actions, and similar topics often include multi-line code examples or configuration snippets.
When those code blocks are displayed in a single color, they become harder to scan. Keywords, strings, comments, and properties are less distinguishable, which makes the code feel heavier to read than the surrounding text.
In a tech blog, code is not just a supplement to the article. It is an important part of understanding the topic. For that reason, I decided that minimal syntax highlighting was worth adding for readability.
## Why I Chose Shiki
There are several options for syntax highlighting, such as Prism.js and highlight.js. If the only goal were to keep things small, those could also be reasonable choices.
Still, I chose Shiki this time.
The main reason is that Shiki fits well with a build-time highlighting approach.
This blog does not assemble article content on the client side. Markdown is converted to HTML at build time, and the browser receives mostly finished HTML. Shiki works naturally with this approach.
Instead of parsing and highlighting code in the browser, Shiki can generate pre-highlighted HTML ahead of time. That means readers do not need to run extra JavaScript just for syntax highlighting after opening an article.
For this blog, the requirement was clear: make code easier to read without increasing the client-side runtime cost. Shiki matched that requirement well.
## Why I Did Not Build My Own Highlighter
I also considered building a very small syntax highlighter myself.
For example, if I only needed to support TypeScript and JSON, it might look possible to use regular expressions to color keywords and strings.
But that approach quickly reaches its limits.
Once JavaScript, TypeScript, CSS, HTML, JSON, YAML, Shell, and other formats are involved, language-specific syntax differences become hard to ignore. Comments, strings, template literals, nesting, escaping, attributes, and configuration-specific formats all need to be handled carefully.
A half-finished custom highlighter may look lightweight at first, but it can become expensive to maintain. Every small rendering issue would need manual fixes, and I could end up spending more time adjusting the highlighter than writing articles.
The goal of this blog is not to build every mechanism from scratch. It is to keep learning and development notes readable. From that perspective, leaving syntax parsing to Shiki was the more natural choice.
## How I Think About Adding Dependencies
I try not to add dependencies to this blog too casually.
Adding a library does not only affect bundle size. It also brings maintenance work, updates, configuration, compatibility concerns, and security checks. Client-side dependencies in particular should be handled carefully.
However, avoiding dependencies completely is not the goal. Rebuilding necessary functionality by hand and reducing quality or maintainability would be missing the point.
In this case, Shiki had a clear reason to exist.
- It improves the readability of code in technical articles.
- It avoids running syntax highlighting logic in the browser.
- It provides a familiar look close to VS Code.
- It offers better parsing accuracy and maintainability than a custom implementation.
- It works well with Astro and a static site architecture.
Given these points, I felt that adding this dependency was justified.
## What I Kept in Mind During Implementation
Adding Shiki does not mean I want to make the blog unnecessarily rich.
This blog should remain static and lightweight. So I use syntax highlighting with the following principles in mind:
- Do not run syntax highlighting JavaScript after the article loads.
- Focus on the languages that are actually needed.
- Avoid excessive themes and decorations.
- Prioritize readability in code block design.
- Do not sacrifice the overall page performance.
The important part is not simply using Shiki. The important part is making code easier to read while keeping the blog fast and lightweight.
## Conclusion
Adding features to a fast tech blog is harder than it may seem.
If performance were the only goal, the fastest option would be to add nothing. But if that makes technical articles harder to read, the blog becomes less useful.
I introduced Shiki not to make the design look fancy, but to make code feel natural to read without increasing the client-side runtime cost.
Fast, lightweight, and readable.
I want to keep choosing only what is necessary while maintaining that balance.
---
---
title: "Lighthouse Scores Are Not the Same as Perceived Performance"
description: "What I learned after implementing CSS View Transitions on my static blog — how measured performance and perceived speed can tell very different stories."
lang: en
url: "https://engineer-blog.tomoki-ttttt.workers.dev/en/articles/performance-and-transition-ux/"
publishedAt: "2026-05-16T17:00:00.000Z"
tags: ["performance", "frontend", "astro", "ux", "web"]
---
## Introduction
Recently, I rebuilt my personal tech blog.
For this blog, I have been paying a lot of attention to performance.
I wanted it to be lightweight, fast, and free from unnecessary client-side JavaScript.
That is why I chose Astro and built the blog as a mostly static website.
At first, I cared a lot about Lighthouse scores.
FCP, LCP, TBT, CLS, Speed Index.
Seeing those numbers improve felt good, and it made performance improvements feel visible.
But while building the blog, my thinking started to change.
**A good Lighthouse score and a good user experience are not always the same thing.**
The moment that made this concrete for me was when I added SPA-style page transition animations to the blog.
In this article, I want to write about what that experience taught me — specifically, the gap between measured performance numbers and how fast a site actually feels.
## At first, I cared a lot about Lighthouse
When working on performance, Lighthouse is one of the easiest tools to start with.
It gives you a score.
It shows metrics.
It tells you what can be improved.
Especially in personal projects, it is easy to get lost and wonder what to improve first.
In that sense, Lighthouse is very helpful.
I also started by looking at Lighthouse quite a lot.
- What is the Performance score?
- How fast is FCP?
- How fast is LCP?
- Is there any TBT?
- Is CLS close to zero?
- Is Speed Index acceptable?
I checked these numbers while reducing CSS, removing unnecessary JavaScript, and reviewing images and fonts.
That itself was not a bad thing.
In fact, Lighthouse was very useful for improving the initial page load.
But after a while, I started to feel that something was missing.
## Good scores, but not always a good feeling
The Lighthouse score was good.
The initial load was fast.
LCP was fine.
TBT was almost zero.
CLS was not a problem.
But when I actually used the site, sometimes it did not feel as good as the numbers suggested.
For example:
- There was a slight delay after clicking a link.
- Page transitions felt too abrupt.
- Clicks did not always feel responsive.
- Going back and forward felt a little stiff.
- The numbers looked fast, but the experience did not always feel fast.
These things are hard to notice if you only look at Lighthouse.
Lighthouse is mainly useful for evaluating page load performance.
It is especially helpful for understanding the first load.
But users do not only experience the first load.
They move from the article list to an article.
They move from one article to another.
They go back to the homepage.
They open tag pages.
They navigate around the site many times.
That kind of experience cannot be fully measured by Lighthouse alone.
## Initial load speed and page transition feel are different things
I think there are several kinds of “speed” on the web.
For example:
- How fast the first page appears
- How fast the main content becomes visible
- How quickly the site responds after a click
- How fast page transitions complete
- How smooth scrolling and interactions feel
- Whether the site feels pleasant to use
These are related, but they are not exactly the same.
Lighthouse is good at evaluating the initial loading experience.
And of course, that matters a lot.
For a blog, many visitors come directly from search engines or social media, so the first page load should be fast.
But if you also care about visitors moving around inside the site, the first load is not enough.
Does the page transition feel good?
Does the site respond immediately after a click?
Does the screen change naturally?
Does the transition interrupt the reading flow?
I started to feel that these things matter a lot too.
## MPA can be faster, but SPA can feel faster
This blog is basically built like an MPA.
Astro generates static HTML, and the site does not ship more client-side JavaScript than necessary.
For a blog, I think this is a very natural architecture.
MPA is simple and strong.
Each page can be delivered as an independent HTML document.
It is easier to make the first load fast.
There is less dependency on JavaScript.
It is more robust.
It is also easier to handle SEO and accessibility.
For a personal blog, MPA is a very good fit.
On the other hand, SPA also has its own strengths.
The entire page does not reload during navigation.
Only the necessary data or components are replaced.
It is easier to give immediate feedback after clicking a link.
It is also easier to add smooth transition animations.
As a result, even if the actual network or processing time is not always shorter, an SPA can sometimes **feel faster**.
I think this is an important point.
For example, even if an MPA is faster in raw numbers, users may feel a delay if the page suddenly turns blank before the next page appears.
On the other hand, in an SPA, even if some processing is happening in the background, the experience can feel faster if an animation starts immediately after a click and the screen changes smoothly.
In other words, there is **measured speed** and **perceived speed**.
These two are related, but they are not the same.
## I actually implemented CSS View Transitions
I added SPA-style page transitions to this blog to see what would happen.
The tools I used were Astro's `ClientRouter` and the browser-native View Transitions API.
`ClientRouter` is Astro's client-side router. It removes full-page reloads during navigation, giving the site SPA-like behavior. Combined with the View Transitions API, it makes it possible to animate elements between pages using nothing but CSS.
There were two main things I set up.
First, I assigned `view-transition-name` to the header, main content area, and footer.
This tells the browser which elements to treat as distinct layers during a transition.
```css
.site-header { view-transition-name: site-header; }
.site-main { view-transition-name: site-main; }
.site-footer { view-transition-name: site-footer; }
```
Second, I disabled animation on the header and footer — keeping them instant — and only animated the main content area.
If the header fades in and out on every navigation, it becomes distracting. Animating only the content creates a natural sense of "the page changed" without the chrome feeling unstable.
```css
::view-transition-old(site-header),
::view-transition-new(site-header),
::view-transition-old(site-footer),
::view-transition-new(site-footer) {
animation: none;
}
```
Before and after this implementation, I ran Lighthouse again.
The scores barely moved. Performance, FCP, LCP, TBT, CLS — the numbers looked the same.
But the experience of clicking a link felt noticeably different.
Before: click a link, the page flashes white, the next page appears.
After: the content fades out, the next content fades in. The header stays exactly where it is throughout.
It was not that the site felt "faster" in a measurable sense.
It felt more like the friction was gone.
That was the clearest example I found of the gap between measured performance and perceived speed.
Lighthouse had nothing to say about it. The improvement was real, but invisible to the tool.
## Animation is not the enemy
When working on performance, it is easy to think that animations should be removed because they can make a site heavier.
Of course, excessive animations are not good.
For example:
- Too many scroll-linked animations
- Heavy JavaScript calculations
- Animations that trigger layout recalculations
- Janky motion on low-end devices
- Animations that interrupt reading
These can make the experience worse.
But animation itself is not bad.
In fact, I think appropriate animation can improve perceived performance.
For example, animation can help when:
- The site responds immediately after a click
- A page transition feels natural
- The change on the screen has context
- The user does not lose track of where they are
- Waiting time feels less like waiting
If you only look at performance numbers, animation may look unnecessary.
But from a UX perspective, it can sometimes be useful.
What matters is not whether to use animation or not.
What matters is **why the animation exists**.
If it is only decoration, maybe it should be removed.
But if it gives feedback or makes page transitions feel more natural, it may be worth the small cost.
## A fast site and a pleasant site are not exactly the same
While building this blog, I realized that a fast site and a pleasant site are not exactly the same.
A fast site can be measured to some extent.
Fast FCP.
Fast LCP.
Low TBT.
No CLS.
Small JavaScript.
Light HTML.
These things are important.
But a pleasant site needs more than that.
It responds quickly when clicked.
Transitions feel natural.
Scrolling is smooth.
The screen does not change in a confusing way.
The reading experience is not interrupted.
These things do not always appear clearly in performance scores.
That is why I think performance work should not rely only on Lighthouse.
Actually using the site is just as important.
Especially on mobile.
Tap links.
Move between pages many times.
Use the back button.
Open an article from the article list.
Finish reading one article and move to another.
By repeating these normal actions, you can find discomfort that numbers alone do not reveal.
It is a small and manual process, but I think it matters a lot.
## How far should a personal blog go?
That said, I do not think a personal blog should always become a rich SPA-like application.
A blog is mainly a place to read.
If the initial load becomes slower, JavaScript increases too much, or the site becomes fragile, that defeats the purpose.
So my current thinking is something like this:
- Make the initial load as static and fast as possible.
- Keep the basic architecture simple and MPA-like.
- Still care about page transition feel.
- Add lightweight animations when they improve UX.
- Use prefetch carefully and only where it makes sense.
- Bring in SPA-like behavior only where it is actually useful.
There is no need to make everything a SPA.
But just because a site is an MPA does not mean page transition UX should be ignored.
Even on a static blog, CSS View Transitions and limited prefetching can make the experience feel somewhat closer to an SPA.
What matters is not the technical category.
What matters is how the site feels when people actually use it.
## Lighthouse is a starting point, not the goal
Lighthouse is useful.
I still trust it as an entry point for performance work.
I will keep using it.
But I think it can be dangerous to treat the Lighthouse score itself as the final goal.
A site can have a good score but still feel unpleasant to use.
You might remove useful feedback just to protect the score.
You might focus only on the first load and forget about navigation and browsing experience.
That can happen.
So now I see Lighthouse as a starting point, not the goal.
First, use Lighthouse to find obvious problems.
Then, actually use the site.
Look at both numbers and experience.
I think that balance is important.
## Conclusion
While building my personal tech blog, I spent a lot of time thinking about performance.
At first, I mostly cared about improving Lighthouse scores.
But as I built and used the site, I started to feel that page transition UX and interaction feedback are just as important.
Web performance is not only about numbers.
FCP, LCP, and other metrics are important.
But how the site feels when users interact with it also matters.
I want this blog to be not only fast, but also pleasant to read.
I will keep improving it while balancing initial load performance and page transition experience.
---
---
title: "What I Pay Attention to When Building a Fast Engineering Blog"
description: "A practical note on HTML, JavaScript, CSS, images, fonts, dependencies, Lighthouse metrics, and a checklist for keeping an engineering blog fast."
lang: en
url: "https://engineer-blog.tomoki-ttttt.workers.dev/en/articles/tech-blog-performance-notes/"
publishedAt: "2026-05-16T15:00:00.000Z"
tags: ["Astro", "Web Performance", "Lighthouse", "Engineering Blog"]
---
## Introduction
On this blog, I try to keep the site lightweight, fast, and comfortable to read.
In this article, I will summarize the implementation details I pay attention to when building a fast engineering blog.
More specifically, I will cover the following topics:
- serving article content as static HTML
- keeping JavaScript away from the initial rendering path as much as possible
- treating CSS as part of the critical rendering path
- keeping images, icons, and Open Graph images lightweight
- avoiding unnecessary web fonts, especially for Japanese text
- keeping dependencies minimal
- how I look at Lighthouse measurements
- checking desktop and mobile results separately
- a checklist for keeping an engineering blog fast
This article is less about framework selection and more about practical implementation notes for keeping a blog lightweight.
The examples are based on a statically generated Astro blog, but many of the ideas also apply to blogs and documentation sites built with Next.js, Vite, or other frontend stacks.
## Serve article content as static HTML
The most important thing for an engineering blog is that the article content should be readable as quickly as possible.
For that reason, I prefer generating article pages as static HTML at build time instead of assembling the content on the client side.
The basic idea is simple:
Markdown is converted into HTML during the build.
The browser receives HTML that it can render immediately.
The article content does not need to wait for JavaScript execution.
This makes the initial rendering path much simpler.
What I especially want to avoid is involving too much JavaScript in rendering the article body.
For example, the following patterns can make a blog unnecessarily complex:
- fetching article data on the client side
- parsing Markdown in the browser
- rendering the entire article body with React
- requiring routing or state management before the content appears
There are cases where these approaches make sense for web apps.
But an article page on an engineering blog is mostly for reading.
If that is the case, serving the content as plain HTML is usually the fastest and most robust option.
## Keep the HTML simple
Generating static HTML does not automatically mean the page is perfect.
If the HTML itself becomes too large, it can still affect the initial load.
Engineering blogs can easily accumulate a surprising amount of HTML:
- article content
- code blocks
- Open Graph metadata
- structured data
- alternate links for multiple languages
- common layout elements
So I try to keep the HTML reasonably small and simple.
The things I pay attention to include:
- avoiding unnecessary wrapper elements
- keeping shared components lightweight
- keeping metadata in the `head` necessary but not excessive
- avoiding oversized SEO or OGP metadata
- checking whether the article HTML size is unusually large
Shared layouts require extra care.
Headers, footers, SEO components, theme initialization, and navigation are included on many pages.
If these common parts become heavy, every article page becomes heavy.
It is not enough to optimize only the article body.
I also need to keep an eye on what is included in the shared layout.
## Keep JavaScript away from the initial render
JavaScript needs to be handled carefully when thinking about performance.
Its cost is not just the file size.
The browser also needs to parse, compile, and execute it. It can also block the main thread.
Even small features can add up on an engineering blog.
Examples include:
- theme switching
- copy buttons for code blocks
- sticky table of contents
- site search
- comments
- animations
- analytics
- external widgets
- syntax highlighting
These features can be useful, but if they are loaded everywhere, the site gradually becomes heavier than necessary.
Before adding JavaScript, I try to ask the following questions:
- Is this JavaScript really needed for the initial render?
- Can this be done with HTML and CSS?
- Does this need to be loaded on every page?
- Can the article still be read without this feature?
- Does this block the main thread?
- Can I remove this later without rewriting the whole site?
Astro makes it easy to add client-side interactivity only where needed.
But that does not mean I should turn every part of the article page into an interactive component.
If I keep adding islands everywhere, the site will slowly move closer to a heavy frontend app.
For this blog, my default rule is simple:
The article body should not depend on JavaScript to appear.
Some small scripts may still be necessary, such as theme initialization.
But even then, I try to keep them small and fast.
## Treat CSS as part of the critical rendering path
CSS is not just for styling.
It directly affects the initial rendering path.
CSS can block rendering.
Even if the HTML has already arrived, the browser may wait for CSS before painting the page.
For that reason, I pay attention to the following:
- keeping shared CSS from growing too much
- avoiding loading homepage-only styles on article pages
- keeping layouts simple
- avoiding expensive visual effects
- not applying animations or transitions globally without thinking
- checking whether scrolling feels heavy on mobile
Some CSS properties can become expensive when used too much.
For example:
- `box-shadow`
- `filter`
- `backdrop-filter`
- `blur`
- large gradients
- complex sticky elements
- too many transitions
- scroll-linked animations
This does not mean I never use them.
But on an engineering blog, the main experience is reading.
I do not want decorative effects to make scrolling or rendering feel heavy.
I also pay attention to how CSS is emitted during the build.
Inlining small CSS can reduce requests, but inlining too much CSS can become counterproductive.
So I look at CSS not only as a styling layer, but as something that can affect rendering performance directly.
## Keep images, icons, and OGP assets lightweight
Images can easily make a page heavy.
For a travel blog or a media site, images may be the main content.
But for an engineering blog, the main content is usually text.
Images are sometimes necessary.
Screenshots can make an article easier to understand.
Icons and Open Graph images are also useful.
Still, images need to be handled carefully.
I try to follow these rules:
- avoid unnecessary hero images
- avoid decorative images that do not support the article
- crop screenshots to only the necessary area
- consider lightweight formats such as WebP or AVIF
- specify `width` and `height` to avoid layout shift
- avoid preloading too many images
- avoid oversized Open Graph images
`preload` is especially something I want to use carefully.
It can be useful, but if I preload too many things, it may compete with more important resources.
For an engineering blog, the highest priority is the article content.
I want the text to become readable before spending too much effort on loading decorative assets.
## Use system fonts by default
Web fonts can make a site look polished.
However, Japanese web fonts can be large, and the loading cost can be significant.
For an engineering blog, I care more about readability and speed than strong visual branding.
That is why I usually prefer system fonts.
The benefits are straightforward:
- no additional font request
- faster text rendering
- a look that matches the operating system
- less risk of font loading flashes
- fewer layout shifts caused by font replacement
This does not mean web fonts are bad.
For brand sites or portfolios, typography can be a major part of the design.
But for a personal engineering blog, I prefer prioritizing readability, speed, and long-term maintainability.
## Keep dependencies minimal
One of the most effective ways to keep a site fast is to avoid unnecessary dependencies.
Adding one library does not only add its own size.
It can also add transitive dependencies, build complexity, update work, and security maintenance.
I am especially careful with dependencies that end up in the client-side bundle.
Before adding something like the following, I try to consider whether it is truly necessary:
- date formatting libraries
- animation libraries
- UI component libraries
- search libraries
- syntax highlighting
- Markdown extensions
- image galleries
- comment systems
For an engineering blog, simple date formatting or tag rendering can often be implemented without a large library.
Search and syntax highlighting can be useful, but they are not always needed from day one.
If I add them, I want to make sure they do not affect the initial load of every page.
Keeping dependencies small helps not only performance, but also maintainability.
It is less about making the site smaller once, and more about reducing the number of ways the site can become heavy over time.
## Serve the site as static assets
This blog is built and served as static assets.
The advantage of static delivery is that the server does not need to generate HTML for every request.
There is no database query.
There is no API call.
There is no per-user page rendering.
The server simply returns prebuilt HTML, CSS, JavaScript, and images.
This works very well for a personal engineering blog.
The benefits include:
- simple architecture
- fewer things that can break
- easy caching
- low operational cost
- smaller security surface
- less dependency on server-side response time
A web app may need dynamic rendering.
But for a blog whose main purpose is reading articles, static delivery is often the fastest and simplest option.
## Look at real Lighthouse measurements
Lighthouse is very useful when thinking about performance.
However, I try not to look only at the total score.
Instead, I look at each metric separately.
Here are some example measurements from this blog.
| Environment | FCP | LCP | TBT | CLS | Speed Index |
|---|---:|---:|---:|---:|---:|
| Mobile example 1 | 0.8s | 0.9s | 0ms | 0 | 0.8s |
| Desktop example 1 | 0.4s | 0.4s | 0ms | 0 | 0.5s |
| Mobile example 2 | 0.9s | 0.9s | 0ms | 0.001 | 2.2s |
| Desktop example 2 | 0.3s | 0.3s | 10ms | 0.001 | 0.3s |
FCP, LCP, TBT, and CLS are generally very good.
But the mobile Speed Index sometimes fluctuates more than expected.
This is important.
Even if LCP is fast, it does not always mean the entire visual loading experience is equally stable.
Lighthouse results can vary between runs.
Network conditions, CPU performance, rendering timing, images, CSS, and other factors can affect the result.
So I try not to judge performance based on a single run.
I prefer running it multiple times and looking at the overall tendency.
## Target numbers for a lightweight engineering blog
This is only my personal reference, but I think a statically generated engineering blog can aim for fairly aggressive numbers.
After thinking through the numbers with ChatGPT, I use something like the following as a rough guideline.
| Metric | Theoretical value | Practical target | Acceptable range |
|---|---:|---:|---:|
| FCP | 0.2–0.5s | 0.5–1.0s | under 1.5s |
| LCP | 0.3–0.8s | 0.8–1.5s | under 2.0s |
| TBT | 0ms | 0–50ms | under 100ms |
| CLS | 0 | 0–0.01 | under 0.05 |
| Speed Index | 0.3–0.8s | 0.8–1.5s | under 2.5s |
These numbers assume a very lightweight site.
They would be too strict for many other types of sites, such as:
- media sites with many ads
- travel blogs where images are the main content
- e-commerce sites
- dashboards
- authenticated web apps
- sites with many third-party scripts
- sites using multiple web fonts
These targets make sense only because this blog is mostly static HTML with minimal images and JavaScript.
## Desktop and mobile are different
A site that feels fast on desktop does not automatically feel fast on mobile.
I noticed this clearly while measuring this blog.
On desktop, FCP and LCP can be around 0.3 to 0.4 seconds.
But on mobile, the same page can sometimes show a Speed Index above 2 seconds.
There are many possible reasons:
- different CPU performance
- different network conditions
- different viewport size
- different CSS application timing
- different font rendering behavior
- different image handling
- stricter Lighthouse mobile conditions
During development, it is easy to check everything only on a desktop machine.
But many readers may open the article on a phone.
So I try not to call the site fast just because the desktop score looks good.
At minimum, I check both Mobile and Desktop in Lighthouse.
When possible, I also check the site on a real device or with DevTools Performance.
## Do not optimize only for numbers
Lighthouse is useful, but optimizing only for Lighthouse can be misleading.
At first, I cared a lot about improving FCP, LCP, TBT, and CLS.
Those metrics are important for the initial load.
But a site experience cannot be explained by one score.
Performance should be viewed from multiple angles:
- initial load
- scrolling
- response to taps and clicks
- mobile experience
- desktop experience
- consistency across multiple measurements
- total resource size
- main thread work
This article focuses mainly on the initial loading path.
But when improving a real site, I think it is important to check both metrics and actual feel.
Numbers are useful because they reveal problems.
But the final goal is not a perfect score.
The final goal is a site that feels fast and comfortable to read.
## Checklist for keeping an engineering blog fast
Here is the checklist I use to keep a technical blog lightweight.
### HTML
- [ ] Article content is generated as HTML at build time
- [ ] Article content is not generated by client-side JavaScript
- [ ] There are no unnecessary wrapper elements
- [ ] Shared layouts are not too heavy
- [ ] Metadata in the `head` is not excessive
- [ ] OGP, canonical, and hreflang tags are necessary and sufficient
- [ ] HTML size is not unusually large
### JavaScript
- [ ] Unnecessary JavaScript is not loaded during the initial render
- [ ] Article pages do not casually introduce React hydration
- [ ] Article content is readable without JavaScript
- [ ] Small necessary scripts, such as theme initialization, stay small
- [ ] Third-party scripts are minimized
- [ ] Features such as copy buttons, search, and sticky TOC are evaluated before adding
- [ ] DevTools does not show long main-thread blocking tasks
### CSS
- [ ] Shared CSS is not bloated
- [ ] Article pages do not load unnecessary homepage styles
- [ ] CSS is treated as a render-blocking resource
- [ ] `box-shadow`, `filter`, `backdrop-filter`, and `blur` are not overused
- [ ] Animations and transitions are not applied globally without reason
- [ ] Sticky elements and heavy decoration do not make scrolling feel slow
- [ ] Mobile scrolling feels smooth
### Images and icons
- [ ] Unnecessary hero images are avoided
- [ ] Images use lightweight formats such as WebP or AVIF when appropriate
- [ ] Images have explicit `width` and `height`
- [ ] Screenshots are cropped to the necessary area
- [ ] Too many images are not preloaded
- [ ] Favicons, icons, and Open Graph images are not excessive
- [ ] Images do not take priority over article content
### Fonts
- [ ] Japanese web fonts are not loaded casually
- [ ] System fonts are considered first
- [ ] Font loading delays are checked
- [ ] Layout shifts caused by font replacement are checked
### Dependencies
- [ ] A library is added only when it is truly necessary
- [ ] Dependencies that enter the client-side bundle are reviewed carefully
- [ ] Simple tasks such as date formatting and tag rendering are not over-engineered
- [ ] Search features are evaluated for index size and JavaScript size
- [ ] Syntax highlighting does not add unnecessary cost to every page
- [ ] Unused dependencies are removed
### Delivery and build
- [ ] The site can be served as static files
- [ ] Initial rendering does not depend on a database or API
- [ ] Prebuilt HTML, CSS, JavaScript, and images are served directly
- [ ] The file structure is cache-friendly
- [ ] Build output size is checked after deployment
- [ ] CI checks build, lint, and typecheck
### Measurement
- [ ] Lighthouse is checked for both Mobile and Desktop
- [ ] FCP, LCP, TBT, CLS, and Speed Index are reviewed separately
- [ ] A single Lighthouse run is not treated as the final truth
- [ ] Network requests and transfer sizes are checked
- [ ] Coverage is used to find unused CSS and JavaScript
- [ ] Performance is used to inspect main-thread work
- [ ] Mobile scrolling is checked on a real or simulated device
- [ ] Desktop speed alone is not considered enough
- [ ] The site is not judged only by Lighthouse scores
## Conclusion
Building a fast engineering blog is not about adding one magic setting.
It is mostly about avoiding small, unnecessary costs.
Serve article content as static HTML.
Keep JavaScript away from the initial rendering path.
Treat CSS as part of the critical rendering path.
Handle images and fonts carefully.
Avoid unnecessary dependencies.
Measure both desktop and mobile.
These small decisions add up.
At the same time, numbers are not everything.
A good Lighthouse score is useful, but the real goal is not the score itself.
The goal is to make the blog feel fast, stable, and comfortable to read.
I want to keep improving this blog while maintaining that balance.
---
**Update (2026-05-21):** After publishing this article, I added syntax highlighting to this blog using Shiki. The reasoning behind the decision — including how I thought about the trade-off between readability and keeping the site lightweight — is covered in [Why I Added Syntax Highlighting with Shiki to My Fast Tech Blog](/en/articles/add-shiki-syntax-highlight/).
---
---
title: "Adding Travel Tips to the Loading Time of an AI Travel Planner"
description: "How I designed the generation waiting experience in Tabidea, an AI travel planner, by turning loading time into a moment of anticipation with a progress bar and travel tips."
lang: en
url: "https://engineer-blog.tomoki-ttttt.workers.dev/en/articles/tabidea-travel-tips-loading-ux/"
publishedAt: "2026-05-11T19:00:00.000Z"
tags: ["AI", "UX", "Indie Development", "Travel", "Tabidea"]
---
When building an app that uses AI, there is one problem that is almost impossible to avoid: **generation time**.
If the app only needs to return a short chat-style response, the wait may not be too noticeable. But when an app generates a more complete result, users may have to wait several seconds, ten seconds, or sometimes even longer.
I am currently building **Tabidea**, an AI travel planner. It is a web app that creates travel itineraries based on the destination, number of days, preferences, and other conditions entered by the user.
https://tabide.ai
The frontend is built with **Next.js / React / TypeScript**. For itinerary generation, I use the **Gemini API**. After generating an itinerary, I also plan to combine it with external APIs such as the **Google Maps API** to supplement spot information and improve the realism of routes and travel times.
For authentication and user data, I use **Supabase**.
In other words, Tabidea is not just an app that sends a prompt to an LLM and displays the returned text. It is a web app that combines AI generation, external APIs, user state, and UI presentation.
At first, I thought that if I passed the user’s conditions to the AI, it would return a reasonably good itinerary. But once I actually started building the product, I realized that a travel planner cannot stop at simply generating text.
To make an itinerary usable, the app needs to consider things like:
* whether a place actually exists
* whether the distance between spots is realistic
* whether the travel time is reasonable
* whether opening hours or closed days conflict with the plan
* whether the flow from morning to afternoon to evening feels natural
* whether the itinerary matches the user’s preferences
In other words, travel itinerary generation does not end with “returning text that looks plausible.” The AI’s suggestions need to be compared against real-world movement and spot information so that the final itinerary feels as natural as possible.
As a result, generation takes time.
In this article, I will write about how I thought about the waiting experience while building an AI travel planner. In the end, I arrived at a UI that shows **a progress bar and travel tips**, instead of relying only on a loading spinner.
## The waiting problem in AI apps
In AI apps, there is almost always a delay between the moment the user submits input and the moment the result is displayed.
Regular web apps also have waiting time, such as database reads or external API requests. But AI generation feels a little different.
With a database or a normal API, the requested data and processing are usually somewhat predictable. With AI generation, however, the output length and processing time can vary depending on the user input. From the user’s perspective, it is often hard to tell **what is happening right now**.
After pressing a button, the screen may look frozen. Several seconds may pass without any visible change. Then the user starts wondering:
* Is it actually working?
* Did an error happen?
* Should I press the button again?
* How long do I need to wait?
This is especially difficult because Tabidea is a **web app**.
Compared with mobile apps, I feel that users tend to leave web apps more easily when there is waiting time. In a mobile app, users may be more willing to interpret a short pause as “the app is processing something.” On the web, even a small delay in rendering or navigation can quickly feel like the page is heavy or broken.
Web apps also make it very easy for users to reload the page. If they feel uncertain, they may press the reload button, go back, or move to another tab.
Technically, it can also be tricky to decide where to keep the generation state. If the state only exists on the client side, it disappears when the user reloads. On the other hand, turning everything into a server-side job with full progress tracking can significantly increase implementation complexity, especially for an indie project.
That is why I felt it was important to first create a screen where users can wait without feeling anxious.
Even if the final generated result is good, users may leave before they ever see it if the waiting experience is bad. In AI apps, the experience during generation is very important.
## Streaming is the typical solution
One of the most common solutions to waiting time in AI apps is **streaming**.
This is the kind of UI used by ChatGPT, where the response appears gradually as it is generated. Streaming has major benefits.
First, users can see that generation is progressing. Because the result appears little by little, the feeling of simply waiting is greatly reduced.
Second, users can start reading before the entire generation is complete. For text-generation apps, streaming is an excellent fit.
However, for the travel planner I am building, I felt that directly using streaming would be difficult.
## Why streaming is hard for a travel planner
In a travel planner, showing partially generated information to users can be risky.
For example, suppose the AI first outputs “Visit spot A on the morning of Day 1.” Later checks may reveal that:
* spot A does not actually exist
* spot A is not open at that time
* moving from spot A to the next spot B is unrealistic
* another spot would make more sense than A
A travel itinerary is not just text. It is a connected structure made of order, movement, and time allocation. Content shown halfway through generation may change later.
Technically, the itinerary data Tabidea wants to handle is not just a long block of text. It is **structured data** with days, time slots, spots, descriptions, and movement information. Even if tokens stream in little by little, that does not mean they can be directly displayed in the UI.
Showing partial JSON or unverified spot names could make the experience more confusing. If a spot appears at first and then disappears later, or if the order changes after the user has already seen it, the user may wonder what the earlier output meant.
For travel planning, “plausible” is not enough. Place names and tourist attractions are especially sensitive. Suggesting a non-existent place would be a serious failure. Even when the place exists, an itinerary is not useful if the movement for the day is unrealistic.
For that reason, I decided that in Tabidea it would be more natural to check and organize the generated content before showing the itinerary as a complete result, rather than streaming the AI output directly.
## Itinerary generation is not just waiting for the AI response
The reason a travel planner takes time is not only that the app is waiting for the AI.
In Tabidea, I do not want to display the LLM’s itinerary draft exactly as it is. Instead, I want to combine it with external APIs and application-side logic where needed, then turn it into a form that can be shown to users.
That means the waiting time includes not only “the AI thinking,” but also “the app checking and formatting the result.”
To generate an itinerary, the app needs to do things like:
1. organize the user’s travel conditions
2. think about possible spots and areas
3. build a flow for each day
4. verify spot existence and location information
5. check whether travel times and ordering are reasonable
6. format everything into a user-facing itinerary
Of course, verifying everything perfectly is not easy. Still, if this is going to be used as a travel planner, I want to reduce obviously non-existent places and unrealistic movement as much as possible.
Adding these checks naturally increases the processing time. But this time is not just meaningless delay. It is also time spent improving the quality of the itinerary.
The problem is that this necessary time is invisible to the user.
Behind the scenes, the app may be generating, checking spots, and formatting the itinerary. But on the screen, it can look like nothing is happening. I felt that how this time is presented is an important UX problem.
## I first tried skeletons and loading spinners
The first thing I tried was the common waiting UI.
Specifically, I used things like:
* loading spinners
* skeleton UI
* text such as “Generating...”
These are easy to implement, and they at least tell the user that something is being processed. But when I used it myself, it felt a little unsatisfying.
A spinner tells users that something is moving. But it does not tell them how long they may have to wait or what is currently happening.
Skeleton UI is great for loading normal lists or card layouts. But I felt that it did not quite match the experience of AI generation, where the app is actively thinking and assembling a result.
This felt especially true for a travel planner. While waiting, the user is imagining a trip they might take. It felt wasteful to make that time feel like nothing more than waiting.
## Adding a progress bar
Next, I added a **progress bar**.
Of course, the progress of AI generation cannot be expressed as an exact percentage. I cannot accurately say, “It is now 37% complete.” API response time and validation time can also vary.
So the progress bar here is not an exact measurement. It is a form of progress expression designed to reduce anxiety.
From the user’s point of view, a screen that changes over time feels much safer than a screen where nothing changes.
The important thing is not to make the progress perfectly accurate. The important thing is to communicate that:
* the process has not stopped
* generation consists of multiple steps
* the result is gradually getting closer
In a travel planner, itinerary generation is not a single simple generation call. The app needs to think about candidate spots, adjust the order, check existence and movement, and create an itinerary that is realistic as a whole.
Even if I do not show all of that internal processing directly, I felt it was meaningful to communicate that the itinerary is being assembled properly.
## The idea of showing travel tips during generation
After that, I started thinking about showing **travel tips** during the generation time.
The idea came from games.
In games, loading screens often show small hints or pieces of trivia while the player waits. The player is technically just waiting, but the time becomes a little more meaningful. Instead of simply staring at a loading screen, they can learn something or feel more connected to the world of the game.
I personally like that experience.
So I wondered if the same idea could be applied to an AI travel planner.
During itinerary generation, the app could show short tips such as:
* small tricks that are useful while traveling
* perspectives for enjoying a destination
* things that are helpful to know before a trip
* cultural or sightseeing trivia
This would let users feel a little more like they are already preparing for a trip while they wait. Instead of just saying “processing,” the UI could create a sense that the journey is about to begin.
## This is surprisingly rare in web apps and AI apps
Tips on loading screens are common in games, but I do not feel they are used as often in web apps or AI apps.
Most apps use one of the following:
* a spinner
* a skeleton screen
* “generating” text
* streaming output
These are not bad choices. In many cases, they are enough.
But for a travel planner, where the user is already imagining the place they might visit, even a few seconds of waiting can become part of the experience.
As a developer, I naturally want AI generation to be as fast as possible. But the waiting time cannot always be reduced to zero.
And in a web app, the longer the wait feels, the more likely it is to cause reloads or abandonment. That is why I felt that turning waiting time into something meaningful could have real value.
## The implementation I adopted
In the end, I decided to show **a progress bar and travel tips** during the waiting time in Tabidea.
I tried skeletons and loading spinners, but they made the wait feel a little too empty. So I kept a progress bar to show that the process is moving forward, and displayed short travel tips below it.
The implementation is simple in concept. When the user starts itinerary generation, the UI switches to a waiting screen. Until the generation process completes, the screen shows a progress bar and tips.
The tips are not just one fixed sentence. Instead, I rotate through several short messages.
I also tried to make sure the screen contains more than a spinner, so that users do not feel unsure about whether the app is actually working.
This makes it easier to create a state where users can feel that:
* the process is progressing
* they are not only waiting, but also seeing useful information
* the time until the itinerary is complete feels like part of the travel experience
This is especially fitting for a travel product. Travel is not only about the final plan. The time spent imagining the trip also has value.
For that reason, showing travel tips during generation feels less like filler and more like part of the product experience.
## What to be careful about when showing tips
That said, showing tips does not mean anything goes.
For travel-related apps, accuracy matters. Information such as opening hours, prices, and transportation details can change. It may be risky to show such information casually as lightweight loading tips.
I think tips are better suited for things like:
* cultural trivia that is unlikely to change often
* general ideas for enjoying travel
* perspectives for thinking about seasons and times of day
* advice about packing or moving around
* ways to look at sightseeing spots
On the other hand, information like the following should probably be shown only when needed, together with a reliable source:
* latest opening hours
* prices
* service suspensions
* precise route guidance
* reservation availability
Tips should also be short. During waiting time, I think one or two sentences per tip is usually enough.
## A waiting screen is not just a place to wait
While thinking about this, I realized that a waiting screen is not merely a waiting area.
In AI apps, generation time is often unavoidable. And in web apps, that waiting time can easily lead to reloads or abandonment.
The question is whether that time becomes:
* time the user is forced to endure
* time spent staring at a spinner
* time spent feeling anxious
or whether it becomes:
* time where users can see that generation is moving forward
* time that increases anticipation for the result
* time that communicates the atmosphere of the product
For Tabidea, the theme is travel. Travel is enjoyable not only after departure, but also during planning.
That is why I wanted the waiting screen during itinerary generation to feel less like a simple processing screen and more like **time spent preparing for the trip**.
## I want to combine this with photos in the future
In the future, I would also like to show **photos from around the world**, or photos related to the user’s destination, during the generation time.
In travel, images have a strong impact. If users see photos of streets, nature, architecture, food culture, or local atmosphere while the itinerary is being generated, they may find it easier to imagine the place they might visit.
This would not be just decoration. It could help turn waiting time into time that builds anticipation for the destination.
However, photos need to be handled carefully too. If the photo has nothing to do with the user’s destination, it may feel strange. If the photo strongly suggests a spot that does not appear in the final itinerary, it may also create confusion.
So I think it would be better to start with general travel photos from around the world, and later move toward photos that are related to the destination or candidate areas being generated.
If tips make waiting time meaningful through reading, photos can make waiting time more exciting through visual imagination.
By combining the two, I think the waiting screen can become an experience that feels even more like Tabidea.
## Conclusion
In AI apps, it is difficult to eliminate generation time completely.
Of course, we should still work to make responses faster. Shortening prompts, parallelizing processing, and reducing unnecessary API calls are all important.
But even after optimization, some waiting time will remain.
If streaming fits the product, gradually showing the output is very effective. But for apps like travel planners, it may be more natural to show a checked and organized result all at once rather than exposing intermediate output.
In that case, the design of the waiting experience becomes important.
This time, inspired by loading screens in games, I adopted a UI that shows a progress bar and travel tips during the generation waiting time in my AI travel planner.
AI generation time can be treated as nothing more than a weakness. But if it is connected to the theme of the product, it may also become time that increases the user’s anticipation.
For a travel planner, the trip does not begin only when the final itinerary appears.
I think it begins little by little **during the time the user is waiting for the itinerary to be generated**.
Please try the generation waiting experience in **Tabidea**.
https://tabide.ai
---
---
title: "Why I Rebuilt My Tech Blog for Blazing Speed"
description: "From Next.js to Astro. A deep dive into the technical selection, the development process with AI agents, and my vision for this blog."
lang: en
url: "https://engineer-blog.tomoki-ttttt.workers.dev/en/articles/about-this-blog/"
publishedAt: "2026-05-11T15:30:00.000Z"
updatedAt: "2026-05-13T15:00:00.000Z"
tags: ["Astro", "Cloudflare", "TechBlog", "IndieDev"]
---
## Why I Rebuilt My Tech Blog for Blazing Speed
This blog, **"Tomokichi's Engineering Log,"** is a place where I document my learning, development, failures, and improvements as an engineer.
Actually, this isn't a brand-new start. This blog originally existed as a Next.js site. However, I decided to overhaul it completely, starting from the tech stack. This wasn't a random whim, but a result of "lessons learned" through my experience in indie development.
### Why the Rebuild Was Necessary Now
The main reason was that my previous blog had become "over-engineered" for its purpose, losing sight of what a technical blog should be.
* **Excessive Features & Effects**: The previous version relied heavily on animations for visual impact. However, for readers looking for information, these became mere noise. I realized a tech blog should, first and foremost, deliver content instantly.
* **Increasing Maintenance Overhead**: With constant updates and security patches required for React Server Components (RSC), I found myself spending more time maintaining the system than writing articles.
* **Questioning "Right Tool for the Job"**: Next.js is a powerhouse for web apps. But for a static blog with no DB, no Auth, and moderate updates, did I really need such a massive framework? This rebuild is my answer to that question.
Drawing from my experience managing SEO for travel blogs and building full-stack apps like **Tabidea** (an AI travel planner), I redefined what I truly needed: a high-performance space to organize and share my thoughts.
## Behind the Tech Stack: Why Astro?
I explored several modern alternatives, but ultimately settled on **Astro**. Getting here involved some internal debate.
### Other Frameworks Considered
* **Vite + React**: The development experience is incredibly snappy. However, when considering the manual effort of handling Markdown parsing, RSS feeds, sitemaps, and content management, Astro’s specialized content-centric features offered a clear advantage.
* **Remix / TanStack (Router/Start)**: I love the "Web Standards" approach and type-safe routing. But for a site without form actions or dynamic data fetching, these felt like overkill.
* **Vue (Nuxt) / SvelteKit**: Excellent ecosystems, but to maximize development speed by leveraging my existing React/TypeScript expertise, I decided to stick with the tools I know best.
### The Deciding Factor: Island Architecture
Astro is built for content-heavy sites. Its ability to convert Markdown into pure HTML at build time—shipping zero unnecessary JavaScript to the client—was a perfect match.
In particular, **Astro Content Collections** provides a type-safe environment for managing articles, which acts as a powerful "guardrail" when developing alongside AI agents.
## Technical Architecture & Design Philosophy
To ensure long-term, low-maintenance, and high-speed operation, I kept the stack simple and modern:
* **Core**: Astro (Intentional exclusion of React/MDX for the blog body)
* **Styling**: Tailwind CSS v4
* **Tooling**: TypeScript, Biome, pnpm
* **Infrastructure**: Cloudflare Workers Static Assets + GitHub Actions
### A Commitment to "Static First"
My priority is ensuring the site is readable with just static HTML and CSS.
* Article bodies are fully converted to HTML during the build process, making them accessible even in JS-disabled environments.
* I minimize client-side hydration to keep Lighthouse scores near 100 at all times.
* For internationalization (i18n), I avoided complex libraries in favor of a clean, directory-based design.
## Co-creating with AI Agents
A unique aspect of this rebuild is the development process. I now develop with coding agents like Claude Code and Gemini CLI as my constant partners.
In an era where we delegate implementation to AI, it’s crucial for the human developer to articulate **"Design Philosophy and Constraints."**
I explicitly set these constraints for this project:
1. **"No React in the content layer."**
2. **"No client-side dynamic rendering for articles."**
3. **"No unnecessary external scripts."**
By hardcoding these constraints into my prompts and documentation, I prevent the AI from over-complicating the architecture. AI speeds up the implementation, but the human's role is to protect the "purity" of the site.
## Why I Publish in Both Japanese and English
This blog features an English version under the `/en/` path. This is about more than just reach.
The primary source of technical information is almost always English. Being able to explain my thoughts and implementations in English is not just about career options—it’s about sharpening my own clarity of thought. Translating my Japanese thoughts into English is a core part of my growth as an engineer.
## What to Expect Moving Forward
I don't intend to just post "perfect" answers here. Instead, I want to document the messy, real-world trial and error of modern development:
* **Indie Dev Strategy**: Decision-making and technical debt battles from projects like Tabidea.
* **Collaborating with AI**: Practical methods for prompting, reviewing, and building products with AI.
* **Infra & Ops**: Tips for building robust, low-cost systems using Cloud Run, Cloudflare, etc.
* **Frontend UX**: Balancing "usability" and "speed" without just following trends.
* **Security & Maintenance**: Best practices for Auth and secret management in small-scale projects.
## Closing Thoughts
This blog is the result of pausing to ask, "What tools do I actually need?"
I'm not aiming to build a massive media outlet from day one. I want to quietly but passionately stack up the small wins and painful failures I encounter. I hope that when I look back in a few years, I’ll see a clear line representing my growth as an engineer.
Fast, light, and readable.
Welcome to the new "Tomokichi's Engineering Log."