【エセ驚き屋】Astro + Reactが個人ブログに向きすぎている件
そこそこ長めのゴールデンウィーク、せっかくなら自分のサイトを作りたいなーと思って、このサイトを作りました。
というわけで、技術紹介やっていきます。
技術スタックはこんな感じです。
| 役割 | 採用したもの |
|---|---|
| サイト全体 | Astro |
| 一部のインタラクション | React |
| スタイリング | Tailwind CSS / daisyUI |
| アイコン | Tabler Icons |
| 日付処理 | Day.js |
| コンテンツ管理 | Astro Content Collections |
この記事では、このサイトをどういう考えで作ったのか、実装で何をしているのかをざっくり書いていきます。
なぜAstroにしたのか
個人サイトやブログは、基本的には「ページを表示するだけ」の時間が長いです。
もちろん検索やテーマ切り替えみたいなインタラクションはありますが、ページ全体をReactで動かし続ける必要はありません。記事本文、作品ページ、プロフィールの大部分は、ビルド時にHTMLとして生成できれば十分です。
そこでAstroです。
Astroは、デフォルトではほとんどJavaScriptをブラウザに送らず、必要なコンポーネントだけを部分的にハイドレーションできます。この考え方が、個人ブログにはかなり向いていました。
このサイトでも、ページの大部分はAstroコンポーネントで作っています。
Reactを使っているのは、たとえばブログ一覧の検索・絞り込みや、プロフィールページの名刺ダイアログなど、状態管理やイベント処理が必要な部分だけです。
<BlogIndexClient client:load posts={blogPosts} years={years} categories={categories} tags={tags} />client:load を付けたコンポーネントだけがブラウザ側で動きます。
全部をReactアプリにするほどではないけれど、ところどころReactの便利さは欲しい。そういうサイトにはAstroのIslands Architectureがちょうどいいです。
コンテンツはMarkdownで管理する
ブログ記事と作品ページは、Astro Content Collectionsで管理しています。
ディレクトリ構成はだいたいこんな感じです。
src/content/├── blog/│ └── 2026/│ └── first-post/│ └── index.md└── works/ └── 2024/ └── regina.mdMarkdownファイルのfrontmatterにタイトル、カテゴリ、タグ、公開日などを書きます。
---title: "記事タイトル"description: "一覧などに表示する短い説明"category: "開発"tags: ["Astro", "React"]pubDate: 2026-05-06draft: true---ここでContent Collectionsを使っている理由は、frontmatterの型をちゃんと決められるからです。
たとえばブログ記事は、title と category と pubDate が必須で、tags や related は指定がなければ空配列になります。
const blog = defineCollection({ loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}", }), schema: z.object({ title: z.string(), description: z.string().optional(), category: z.string(), tags: z.array(z.string()).default([]), related: z.array(z.string()).default([]), pubDate: z.coerce.date(), updatedDate: z.coerce.date().optional(), draft: z.boolean().default(false), }),});これをしておくと、記事を書いている段階でfrontmatterのミスに気づけます。
日付を書き忘れた、tags を文字列で書いてしまった、画像パスが間違っていた、みたいな事故をビルド時に検出できるのはかなりありがたいです。
やはり型…型は全てを解決する…!!
ブログ一覧はサーバーで集計してReactで絞り込む
ブログ一覧ページでは、記事をそのまま並べるだけでなく、検索、年別アーカイブ、カテゴリ、タグで絞り込めるようにしています。
ただし、記事データの集計までReact側でやる必要はありません。
Astro側で全記事を読み込み、年・カテゴリ・タグごとの件数を集計してから、Reactコンポーネントに渡しています。
const yearCounts = posts.reduce<Map<number, number>>((counts, post) => { const year = post.data.pubDate.getFullYear(); counts.set(year, (counts.get(year) ?? 0) + 1); return counts;}, new Map());React側では、受け取ったデータをもとに現在の検索条件に合う記事だけを表示します。
const filteredPosts = useMemo(() => posts.filter((post) => postMatchesFilters(post, filters)), [posts, filters]);この分担にすると、Reactコンポーネントは「UIの状態管理」に集中できます。
サイト生成時にできることはAstro側で済ませて、ブラウザでしかできないことだけReactに任せる、という感じです。
検索条件をURLに残す
ブログ一覧の検索条件は、URLのquery parameterに同期しています。
たとえば、検索ワードやカテゴリを指定すると、URLがこんな感じになります。
/blog?q=astro&category=開発実装としては、初回表示時にURLから検索条件を読み取り、検索条件が変わったら history.replaceState でURLを書き換えています。
function writeFiltersToUrl(filters: BlogFilterState) { const params = new URLSearchParams();
if (filters.q) { params.set("q", filters.q); }
const query = params.toString(); const nextUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname; window.history.replaceState(null, "", nextUrl);}これで、絞り込んだ状態のURLをそのまま共有できます。
個人ブログでここまで必要かと言われると、正直なくても困りません。 でも、こういう細かいところを作っておくと、自分で使うときに地味に便利です。
関連記事を自動で出す
記事詳細ページには関連記事を出しています。
関連記事は、frontmatterで手動指定できます。
related: ["2026/other-post"]ただ、毎回きっちり指定するのは面倒です。
なので、このサイトでは手動指定を優先しつつ、足りない分を自動で補完するようにしています。
自動補完では、以下の条件でスコアを付けています。
| 条件 | 加点 |
|---|---|
| カテゴリが同じ | +3 |
| 共通タグがある | 1つにつき +2 |
| 公開年が同じ | +1 |
実装はかなり素朴です。
const getRelatedPostScore = (post: BlogPost, candidate: BlogPost) => { const postTags = new Set(post.data.tags); const commonTagCount = candidate.data.tags.filter((tag) => postTags.has(tag)).length;
return ( (candidate.data.category === post.data.category ? 3 : 0) + commonTagCount * 2 + (candidate.data.pubDate.getFullYear() === post.data.pubDate.getFullYear() ? 1 : 0) );};凝った推薦システムではありませんが、個人ブログならこれくらいで十分そうです。
記事が増えてきたら、タグの重みを調整したり、同じシリーズの記事を強めに出したりしても良さそうです。
見出しから目次を作る
記事詳細ページでは、Markdownを render(post) して本文を表示しています。
このとき、本文だけでなく見出し情報も取得できます。
const { Content, headings } = await render(post);この headings から目次を作っています。
見出しはフラットな配列として渡ってくるので、スタックを使ってツリー構造に変換しています。
while (stack.length > 1 && stack[stack.length - 1].depth >= node.depth) { stack.pop();}
stack[stack.length - 1].children.push(node);stack.push(node);これで、h2 の下に h3、その下に h4 がぶら下がるような目次を作れます。
長い記事を書くときは、目次があるだけでかなり読みやすくなります。
古い記事には注意を出す
技術記事は、数年経つと情報が古くなります。
特にフロントエンド周辺は、数年前のベストプラクティスが今でも正しいとは限りません。
そこで、更新日または公開日から3年以上経っている記事には注意表示を出すようにしました。
const yearsAgo = dayjs().diff(dayjs(post.data.updatedDate ?? post.data.pubDate), "year");{ yearsAgo > 3 && ( <div role="alert" class="alert alert-warning mt-4"> この記事は {yearsAgo} 年前のものです。情報の賞味期限切れには十分ご注意ください。 </div> )}未来の自分が古い記事を放置していても、読者に最低限の注意喚起はできます。
画像はAstroのImageコンポーネントを使う
プロフィール画像や作品画像など、リポジトリ内に置いている画像はAstroの Image コンポーネントを使っています。
<Image src={ProfileHeader} alt="Profile Header" class="rounded-field shadow-lg" />作品ページのギャラリー画像も、Content Collectionsのschemaで image() として定義しています。
gallery: z.array( z.object({ src: image(), alt: z.string(), caption: z.string().optional(), }),).default([]);これで、frontmatterに書いた画像パスが間違っているとビルド時に検出できます。
やはり型…型は全てを解決する…!!
テーマ切り替え
テーマはdaisyUIのテーマ機能を使っています。
ライトテーマは winter、ダークテーマは winter-dark として、global.css に定義しています。
@plugin "daisyui" { themes: winter --default, winter-dark --prefersdark;}ユーザーが選んだテーマは localStorage に保存します。
export const themeStorageKey = "chimolab-theme";export const lightTheme = "winter";export const darkTheme = "winter-dark";テーマ切り替えで気をつけたのは、初回表示時のチラつきです。
ページが表示されてからJavaScriptでテーマを変えると、一瞬だけ意図しないテーマで表示されることがあります。
それを避けるため、RootLayout の <head> 内で早めに document.documentElement.dataset.theme を設定しています。
<script define:vars={{ darkTheme, lightTheme, themeStorageKey }}> (() => { const getPreferredTheme = () => window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? darkTheme : lightTheme;
let theme = getPreferredTheme();
try { theme = localStorage.getItem(themeStorageKey) ?? theme; } catch { theme = getPreferredTheme(); }
document.documentElement.dataset.theme = theme; })();</script>こうしておくと、HTMLが描画されるかなり早い段階でテーマが決まります。
トップページの作品表示は明示的に選ぶ
トップページには、最新作品を自動で出すのではなく、表示したい作品IDを明示的に指定しています。
const featuredWorkIds = ["2024/regina"];個人サイトのトップページに出したい作品は、必ずしも最新作とは限りません。
なので、ここは自動化しすぎず、自分で選べるようにしました。
ついでに、IDの重複や存在しないID、下書き作品を指定した場合はビルド時に落とすようにしています。
if (!work) { throw new Error(`トップページ表示作品のIDが見つかりません: ${id}`);}
if (work.data.draft) { throw new Error(`draft の作品はトップページに表示できません: ${id}`);}こういうチェックは、実装時は少し面倒ですが、あとから自分を助けてくれるタイプのやつです。
作ってみての感想
Astroは、個人サイトを作るにはかなりちょうどいい選択でした。
静的に作れるところは静的に作って、必要なところだけReactを使う。この分担が自然にできるのが良いです。
特にこのサイトでは、次のあたりがAstroと相性よく作れました。
- Markdownベースの記事管理
- frontmatterの型チェック
- ビルド時の静的ページ生成
- 必要なReactコンポーネントだけのハイドレーション
- 画像パスや下書き状態のビルド時チェック
逆に、全部をReactで作っていたら、ブログ本文や作品ページのために余計なJavaScriptを抱えることになっていた気がします。
個人ブログやポートフォリオのように「基本は読むサイト、でも一部だけ動かしたい」サイトなら、Astro + Reactの組み合わせはかなり扱いやすいです。
今後は、OGP画像の生成、前後記事リンク、SNS共有ボタンあたりを追加したいです。
ということで、Astro、個人ブログに向きすぎている件でした。