blog.imaikosuke.comを支える技術

blog.imaikosuke.comを支える技術

Astro 6、Tailwind CSS v4、Cloudflare Workers、Obsidian、Pagefind で構成した個人ブログの技術選定。pnpm と mise による環境管理も含め、各技術を選んだ理由をまとめました。

27min

blog.imaikosuke.com は、Astro 6、Tailwind CSS v4、Cloudflare Workers、Obsidian、Pagefind などで構成した個人ブログです。個人ブログを作った人が技術構成を紹介する記事をよく見かけるので、自分も 「なぜそれを選んだか」 を中心にまとめてみました。

TL;DR

分類選択肢選定理由
フレームワークAstro 6.xJS ゼロ設計・Content Layer
言語TypeScript型安全なコンテンツ管理
スタイリングTailwind CSS v4LLM 親和性
コンテンツ管理Obsidian + Markdownファイルベースで Git 管理
ホスティングCloudflare WorkersCloudflare 公式推奨・無料枠で十分
CI/CDGitHub Actions + Wranglerpush to main で自動デプロイ
検索Pagefindビルド時インデックス・サーバー不要
コードブロックExpressive Codeファイル名・diff 表示が標準搭載
フィード@astrojs/rssAstro 公式・XSL スタイル付き
画像管理Cloudflare R2リポジトリにバイナリを置かない
パッケージマネージャーpnpm(mise)高速・ディスク効率が良い

フレームワーク:Astro

普段は Next.js を使っていた

普段の自分のプロジェクトでは Next.js を使うことが多かったです。個人ブログを作るにあたって、せっかくなら普段使わないフレームワークを試してみたい という気持ちがありました。

選択肢として最初から気になっていたのが Astro でした。ちょうどそのタイミングで、Astro が Cloudflare に参画するというニュースが飛び込んできたのも後押しになりました。

JavaScript をゼロにするという設計

Astro を使ってみて改めて納得したのが、ブログというコンテンツに対してちょうどいいサイズ感です。

ブログは「一度生成してしまえば、あとは読むだけ」のものが大半です。Next.js にも output: 'export' による SSG や MDX 統合はあります。しかし、Next.js は React フレームワークである以上、静的 HTML を生成したあとも、ページをハイドレートするために React ランタイムとルーターをブラウザに送り続ける必要があります。

この固定コストは設定で取り除けるものではなく、React Server Components を使った Next.js 16 App Router でも最低 ~92KB の JavaScript がブラウザに届きます(参考:Developers Digest 2026(新しいタブで開く))。Astro のコンテンツページは原則 0KB で、アイランドを1つ足しても ~14KB 程度に収まります。

First Load JSLCPTTI
Astro(アイランドなし)0 KB0.6 s0.6 s
Astro(React アイランド 1 つ)~14 KB0.8 s0.9 s
Next.js 16 App Router(RSC)~92 KB1.4 s1.7 s

記事を表示するだけのページに、使われることのない React ランタイムを毎回配信するのは、複雑さに対してリターンが薄いと判断しました。

Astro は基本的に HTML を出力するフレームワーク で、JavaScript はデフォルトでゼロです。インタラクティブな部分だけを「アイランド」として切り出して、必要なときだけハイドレートできる。今のところ、このブログでは検索モーダルとテーマトグルだけが JavaScript を必要としていますが、そのどちらもコンポーネント単位で閉じていて、ページ全体に影響していません。

Content Layer で型付きコンテンツを管理する

もうひとつ大きかったのが Astro 5.0(新しいタブで開く) で追加された Astro Content Layer の存在です。src/content/posts/ 以下に Markdown を置くだけで、型付きの記事として扱えます。

zod でフロントマターのスキーマを定義することで、記事のメタデータをビルド時に検証できる。title や pubDate が空だとビルドが落ちるので、抜け漏れに気づきやすいです。

コンテンツを「型のある構造体」として扱える設計は、ファイルが増えてからも効いてきます。「記事一覧でタグを取り出したい」「公開済みの記事だけ絞り込みたい」といった操作が、型補完を受けながら書けるのは快適です。まあ、AIエージェントにコーディングさせることが多くなって、あまり型補完の恩恵はなくなってきてるんですけどね。

スタイリング:Tailwind CSS v4

LLM との相性が良い

Tailwind CSS を選んだ理由で、個人的に一番大きかったのが LLM との相性 です。

Tailwind のクラス名は text-sm、bg-surface、rounded-md のように、ほぼ自然言語に近い体系です。LLM はこの一貫した語彙を理解しやすく、コンポーネントのスタイルを生成するときに正確なコードが出てきやすいと思います。

CSS をゼロから書かせると、クラス名の命名規則やスコープの衝突でエラーが増えますが、Tailwind なら「この要素をどう見せたいか」をそのまま伝えればいいです。

v4 から CSS ファーストになった

今回は v4 を使っています。v3 では tailwind.config.js に色やフォントサイズを書いていましたが、v4 では @theme {} ブロックを CSS に直接書きます。

src/styles/global.css
@import 'tailwindcss';
@theme {
--color-canvas: #f4f5f7;
--color-fg: rgba(0 0 0 / 85%);
--color-muted: rgba(0 0 0 / 56%);
--color-edge: rgba(0 0 0 / 10%);
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', Meiryo,
sans-serif;
--text-body: 0.9375rem;
/* ...その他のトークン */
}
@custom-variant dark (&:is(.dark, .dark *));

JavaScript ファイルではなく CSS ファイルにデザイントークンを書く、という考え方の転換です。var() で参照するだけなので、カスタムプロパティとしてそのまま他のスタイルシートでも使えます。ダークモードは @custom-variant dark で定義した .dark クラスの付け外しで切り替えます。

ユーティリティと素の CSS の使い分け

リンクカード(remark-link-card.css)は @apply でほぼ Tailwind に統一しています。

一方で、記事本文の prose スタイル(post.css)と埋め込みコンポーネント(embed.css)はプレーン CSS のままにしています。

理由は、Astro がビルドして出力する HTML には素のタグしか含まれないからです。Tailwind はユーティリティクラスを要素に付与する前提のツールですが、## 見出し から生成された <h2> タグにはクラス属性が存在しません。スタイルを当てるには CSS セレクタでグローバルに指定するしかなく、その用途ではプレーン CSS の方が自然に書けます。

具体的に CSS でなければ解決できない箇所はいくつかあります。

  • 隣接セレクタ:.prose h2 + h3(h2 直後の h3 だけ margin を詰める)のような構造的な前後関係はユーティリティクラスで表現できない
  • content の CSS 関数:content: counter(item) '.' のカスタムカウンターは CSS 関数であり、Tailwind の任意値として渡しても文字列リテラルになってしまい評価されない
  • スコープ付きカスタムプロパティ:--prose-flow のように .prose ブロック内だけで使うローカル変数を Tailwind のグローバルトークンに持ち出すのは過剰

「Tailwind に揃えられるものは揃える、揃えられないものは素の CSS に任せる」という役割の境界を設けることで、スタイルシートの管理が楽になると思います。

ホスティング:Cloudflare Workers

Pages よりも Workers が推奨されるようになった

ホスティング先は Cloudflare Workers です。

Cloudflare には Pages という静的サイト向けのサービスもあります。Pages は廃止されたわけではなく、既存プロジェクトへのサポートも続きます。

ただし Cloudflare は現在、新規プロジェクトには Workers を推奨しており、Pages はメンテナンスモードに移行しています。新機能の開発は Workers 優先で進められるため、新規に始めるなら Workers を選ぶのが自然な判断です。Hono作者の @yusukebe(新しいタブで開く) さんもその考え方を端的に説明しています。

Workers は静的アセットの配信だけでなく、Durable Objects・Cron Triggers・KV・D1 といった機能とシームレスに組み合わせられます。今はブログとして使っていますが、将来的に機能を追加したくなったときにも Workers のままで対応できる、という拡張性も選んだ理由のひとつです。

@astrojs/cloudflare アダプターも Workers をターゲットとして設計されており、画像最適化(imageService: 'compile')との統合もスムーズです。

wrangler.jsonc の設定

実際の設定は wrangler.jsonc に書きます。

wrangler.jsonc
{
"name": "blog",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true }
}

nodejs_compat フラグが重要で、Astro のビルド出力が Node.js の組み込みモジュールを一部使うため、これがないとランタイムエラーになります。

料金:個人ブログなら無料枠で十分

Workers の Free プラン(新しいタブで開く)は Worker スクリプトの実行が 100,000 件/日 まで無料です。静的アセットについては料金ページにこう書かれています。

Requests to static assets are free and unlimited.

— Billing and limitations | Cloudflare Workers docs(新しいタブで開く)

Workers with Assets のルーティングの仕組みも重要で、公式ドキュメントにはこう書かれています。

By default, if a requested URL matches a file in the static assets directory, that file will be served — without invoking Worker code. If no matching asset is found and a Worker script is present, the request will be processed by the Worker.

— Static Assets | Cloudflare Workers docs(新しいタブで開く)

このブログはすべてのページがビルド時に HTML として生成済みの完全静的構成なので、通常のアクセスでは Worker スクリプトが起動しません。Worker が呼ばれるのは 404 など対応する静的ファイルが存在しないケースだけです。どれだけアクセスが増えても静的アセットの配信は無制限枠で配信され続けます。

CI/CD:GitHub Actions

main ブランチへの push で、自動的にビルドからデプロイまで流れます。

  1. フォーマット確認(Prettier)
  2. lint(ESLint)
  3. astro sync(型生成)
  4. 型チェック(tsc)
  5. ビルド
  6. wrangler deploy

各ステップの意図

ビルドの前に lint と型チェックを置いているのには理由があります。ビルドが通ることと、コードが正しいことは別物 だからです。Astro のビルドは型エラーがあっても通ることがあるので、tsc を手前で走らせることで早期に検出できます。

記事を追加するだけの変更でも型チェックは走ります。プラグインやコンポーネントの変更が記事の型定義に影響していないか、毎回自動で確認できる——という保証がこのフローの目的です。

Cloudflare へのデプロイには CLOUDFLARE_API_TOKEN と CLOUDFLARE_ACCOUNT_ID を GitHub Actions の Secret に設定するだけで、あとは wrangler deploy が全部やってくれます。

コンテンツ管理:Obsidian + ファイルベース

Obsidian で執筆する

記事はすべて src/content/posts/ 以下の Markdown ファイルです。CMS は使っておらず、Obsidian を使って書いています。

Obsidian は Markdown ファイルをそのまま扱うエディタなので、リポジトリのファイルをそのまま開いて書き始めることができます。記事もコードも同じリポジトリにあり、Git で一元管理される。「記事ファイルを開けばすぐ書ける」という環境が、個人ブログには合っていると思います。

カバー画像のアップロードも Obsidian の自作プラグインを通じて Cloudflare R2 に上げています。エディタを離れずに画像をアップして URL を貼り付けられるので、執筆の流れが途切れません。

imaikosukeobsidian-cloudflare-r2-sync

Obsidian plugin for syncing images to Cloudflare R2

  • cloudflare-r2
  • obsidian
  • obsidian-plugin
TypeScript ★ 2 0 MIT

このプラグインや Obsidian まわりの詳細は、Obsidianプラグインを自作してブログの画像管理を自動化したにまとめました。

Zod スキーマと下書き管理

記事のフロントマターは Zod スキーマで定義しています。

src/content.config.ts
const posts = defineCollection({
loader: glob({ base: './src/content/posts', pattern: '**/*.md' }),
schema: () =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
cover: r2MediaCoverUrl.optional(),
tags: z.array(z.string()).default([]),
status: z.enum(['not-started', 'in-progress', 'published']).default('not-started')
})
})

status フィールドで下書き管理をしています。published 以外は一覧に表示しないだけ——というシンプルな実装です。書きかけのファイルをリポジトリに置いたまま、気が向いたときに続きを書けます。

Markdown パイプライン

remark と rehype の役割

記事は Markdown で書きますが、そのままでは表現できないものをプラグインで拡張しています。

Astro の Markdown 処理は2段階あります。remark は Markdown の構文木(AST)レベルで動き、rehype は HTML に変換された後のレベルで動きます。どのプラグインをどちらに置くかは、「Markdown の段階で処理したいか、HTML になってから処理したいか」で決まります。

埋め込みとリンクカード(remark)

remark-embeds は URL を埋め込み UI に変換するカスタムプラグインです。記事中に URL を単独行で書くと、YouTube・Spotify・X・GitHub をそれぞれ適切な形で埋め込みます。Spotify はファサード UI(クリックするまで iframe を読み込まない)にしているので、ページの読み込みに影響しません。

remark-link-card-plus は URL からリンクカードを生成するライブラリです。Open Graph のメタ情報を取得してカード形式で表示します。OG 画像のサイズが大きすぎる問題があったので、後続に remark-link-card-images を組んで、ビルド時に WebP へ変換し srcset を付与するようにしています。

HTML 整形と画像最適化(rehype)

rehype-cleanup は生成された HTML をあとから手直しするプラグインです。GitHub Flavored Markdown のタスクリストはアクセシビリティ上の問題があったので(チェックボックスにラベルが関連付けられていない)、ここで <label> で包むように修正しています。

rehype-image-processor は Markdown 内の画像を Astro の画像最適化に差し替えます。![alt](url) と書くだけで、ビルド時に最適なサイズと形式が選ばれます。

検索:Pagefind

Pagefind でクライアントサイド全文検索を入れています。astro-pagefind インテグレーションを追加するだけで、ビルド時にインデックスが自動生成されます。サーバーは不要で、静的ファイルとしてデプロイできます。日本語の検索も問題なく動いています。

外部の検索サービスを使わずに済むので、API キーの管理やレート制限を気にする必要がありません。コストゼロで全文検索が手に入るのは素直に便利です。

ビルド時間への影響は実測したところ 1 記事あたり 0.26s 程度で、記事数が増えると線形にスケールすることが考えられるので、100 記事で 26s 程度になりそうですね。

ただ検索機能が必要になる程の記事数もまだないですし、ビルド時間に困るぐらい、これから記事の投稿を習慣化していきたいです。

コードブロック:Expressive Code

Expressive Code はコードブロックのレンダリングに使っています。シンタックスハイライトだけでなく、ファイル名の表示(title="..." 属性)、差分表示(diff 言語指定)、行のハイライトなどが標準で使えます。

テーマは github-dark と github-light を使い、ページの .dark クラスで切り替えています。

astro.config.ts(抜粋)
expressiveCode({
themes: ['github-dark', 'github-light'],
themeCssSelector: (theme) => (theme.name === 'github-dark' ? '.dark' : ':root:not(.dark)')
})

技術ブログにとってコードブロックの見た目は読みやすさに直結すると思うので、シンタックスハイライトは必須です。

フィード:RSS

/rss.xml で RSS フィードを配信しています。フィードリーダーを使っている方はご利用ください。

@astrojs/rss を使う

Astro 公式の RSS ガイドでは @astrojs/rss パッケージを使う方法が紹介されています。

Astro supports fast, automatic RSS feed generation for blogs and other content websites. RSS feeds provide an easy way for users to subscribe to your content.

— Add an RSS feed | Astro Docs(新しいタブで開く)

このブログでは @astrojs/rss を使っています。主要なフィードリーダーはすべて RSS に対応しているため、Atom は用意せず RSS のみとしました。公式ライブラリを使うことで、余計な依存を持ち込まずに済みます。

実装は src/pages/rss.xml.ts の1ファイルで完結します。記事取得には getCollection() 相当の getSortedFilteredPosts() を使い、published ステータスの記事だけを pubDate 降順で配信しています。

src/pages/rss.xml.ts
import rss from '@astrojs/rss'
import type { APIContext } from 'astro'
import { themeConfig } from '@/config'
import { getSortedFilteredPosts } from '@/utils/draft'
export async function GET(context: APIContext) {
const posts = await getSortedFilteredPosts()
const site = context.site ?? new URL(themeConfig.site.website)
return rss({
title: themeConfig.site.title,
description: themeConfig.site.description,
site,
stylesheet: '/feeds/rss-style.xsl',
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/${post.id.replace(/\.[^/.]+$/, '')}/`
})),
customData: `<language>${themeConfig.site.language}</language>`
})
}

ブラウザで直接見られる XSL スタイルシート

@astrojs/rss には stylesheet オプションがあり、XSL ファイルのパスを渡すだけで <?xml-stylesheet?> 処理命令を自動で注入してくれます。public/feeds/rss-style.xsl を当てることで、ブラウザで /rss.xml を開いたときにスタイル付きのページとして表示されます。

フィードの自動検出(Autodiscovery)

RSS リーダーの多くは、サイトの URL を入力するだけでフィードを自動検出します。<head> に以下の <link> タグを置いておくことで、フィード URL を知らなくてもリーダーが見つけてくれます。

<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss.xml" />

画像管理:Cloudflare R2

ブログ内で使用する画像は Cloudflare R2 に置いています。media.imaikosuke.com というサブドメインで配信し、ブログのリポジトリには画像を直接コミットしていません。

こうすることで、リポジトリはテキストファイルだけのシンプルな状態を保てます。Git の履歴に大きなバイナリが混ざらず、クローンも軽い。

Astro の image.remotePatterns でこのホスト名を許可しておくことで、リモート画像の最適化も有効になります。

astro.config.ts(抜粋)
image: {
remotePatterns: [{ protocol: 'https', hostname: 'media.imaikosuke.com' }]
}

画像のアップロードは Obsidian の自作プラグインから直接 R2 に送る仕組みにしています。詳細はObsidianプラグインを自作してブログの画像管理を自動化したにまとめました。

開発環境:pnpm と mise

pnpm をパッケージマネージャーに使っています。Node.js と pnpm のバージョンは mise で mise.toml に固定しています。ローカルと CI で同じバージョンを使えるので、「手元では動くのに CI だけ落ちる」というズレを防げます。

mise.toml
[tools]
node = "24.15.0"
pnpm = "11.13.0"

まとめ

Zenn や Qiita に記事を書くことも良いことだと思っています。ただ自分の場合は、Astro でブログサイトを実装してみたい という気持ちが元々あり、そのタイミングで Astro が Cloudflare に参画したので、開発し始めました。

個人ブログの良さは、見た目や機能を自分好みに自由に作れることと、投稿できる記事のテーマが自由であることです。技術記事だけでなく、ポエムのような記事も気にせず書けます。ただし、プラットフォームに依存しないことのメリットもある一方で、記事を見つけてもらいづらいというデメリットもあります。

エンジニアには「個人ブログを作ること」がゴールになってしまい、完成した時点で満足して記事をほとんど投稿しない、というパターンがよくあります。自分もそうなりたくないので、ブログを作ることに加えて書くことを意識していきたいです。同じように個人ブログの開発を考えている方の参考に、少しでもなれば嬉しいです!

imaikosuke

imaikosuke

Tokyo-based software engineer. Writing about tech and daily learnings.

@imaikosuke_