【エセ驚き屋】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.md
Markdownファイルのfrontmatterにタイトル、カテゴリ、タグ、公開日などを書きます。
---
title: "記事タイトル"
description: "一覧などに表示する短い説明"
category: "開発"
tags: ["Astro", "React"]
pubDate: 2026-05-06
draft: 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、個人ブログに向きすぎている件でした。