制作記

【エセ驚き屋】Astro + Reactが個人ブログに向きすぎている件

Web Astro React Tailwind CSS

そこそこ長めのゴールデンウィーク、せっかくなら自分のサイトを作りたいなーと思って、このサイトを作りました。

というわけで、技術紹介やっていきます。

技術スタックはこんな感じです。

役割採用したもの
サイト全体Astro
一部のインタラクションReact
スタイリングTailwind CSS / daisyUI
アイコンTabler Icons
日付処理Day.js
コンテンツ管理Astro Content Collections

この記事では、このサイトをどういう考えで作ったのか、実装で何をしているのかをざっくり書いていきます。

なぜAstroにしたのか

個人サイトやブログは、基本的には「ページを表示するだけ」の時間が長いです。

もちろん検索やテーマ切り替えみたいなインタラクションはありますが、ページ全体をReactで動かし続ける必要はありません。記事本文、作品ページ、プロフィールの大部分は、ビルド時にHTMLとして生成できれば十分です。

そこでAstroです。

Astroは、デフォルトではほとんどJavaScriptをブラウザに送らず、必要なコンポーネントだけを部分的にハイドレーションできます。この考え方が、個人ブログにはかなり向いていました。

このサイトでも、ページの大部分はAstroコンポーネントで作っています。

Reactを使っているのは、たとえばブログ一覧の検索・絞り込みや、プロフィールページの名刺ダイアログなど、状態管理やイベント処理が必要な部分だけです。

src/pages/blog/index.astro
<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にタイトル、カテゴリ、タグ、公開日などを書きます。

src/content/blog/2026/example/index.md
---
title: "記事タイトル"
description: "一覧などに表示する短い説明"
category: "開発"
tags: ["Astro", "React"]
pubDate: 2026-05-06
draft: true
---

ここでContent Collectionsを使っている理由は、frontmatterの型をちゃんと決められるからです。

たとえばブログ記事は、titlecategorypubDate が必須で、tagsrelated は指定がなければ空配列になります。

src/content.config.ts
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コンポーネントに渡しています。

src/pages/blog/index.astro
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側では、受け取ったデータをもとに現在の検索条件に合う記事だけを表示します。

src/components/blog/BlogIndexClient.tsx
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を書き換えています。

src/components/blog/BlogIndexClient.tsx
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

実装はかなり素朴です。

src/pages/blog/[...id].astro
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) して本文を表示しています。

このとき、本文だけでなく見出し情報も取得できます。

src/pages/blog/[...id].astro
const { Content, headings } = await render(post);

この headings から目次を作っています。

見出しはフラットな配列として渡ってくるので、スタックを使ってツリー構造に変換しています。

src/components/blog/TableOfContents.astro
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年以上経っている記事には注意表示を出すようにしました。

src/pages/blog/[...id].astro
const yearsAgo = dayjs().diff(dayjs(post.data.updatedDate ?? post.data.pubDate), "year");
src/pages/blog/[...id].astro
{
yearsAgo > 3 && (
<div role="alert" class="alert alert-warning mt-4">
この記事は {yearsAgo} 年前のものです。情報の賞味期限切れには十分ご注意ください。
</div>
)
}

未来の自分が古い記事を放置していても、読者に最低限の注意喚起はできます。

画像はAstroのImageコンポーネントを使う

プロフィール画像や作品画像など、リポジトリ内に置いている画像はAstroの Image コンポーネントを使っています。

src/pages/profile.astro
<Image src={ProfileHeader} alt="Profile Header" class="rounded-field shadow-lg" />

作品ページのギャラリー画像も、Content Collectionsのschemaで image() として定義しています。

src/content.config.ts
gallery: z.array(
z.object({
src: image(),
alt: z.string(),
caption: z.string().optional(),
}),
).default([]);

これで、frontmatterに書いた画像パスが間違っているとビルド時に検出できます。
やはり型…型は全てを解決する…!!

テーマ切り替え

テーマはdaisyUIのテーマ機能を使っています。

ライトテーマは winter、ダークテーマは winter-dark として、global.css に定義しています。

src/styles/global.css
@plugin "daisyui" {
themes:
winter --default,
winter-dark --prefersdark;
}

ユーザーが選んだテーマは localStorage に保存します。

src/lib/theme.ts
export const themeStorageKey = "chimolab-theme";
export const lightTheme = "winter";
export const darkTheme = "winter-dark";

テーマ切り替えで気をつけたのは、初回表示時のチラつきです。

ページが表示されてからJavaScriptでテーマを変えると、一瞬だけ意図しないテーマで表示されることがあります。

それを避けるため、RootLayout<head> 内で早めに document.documentElement.dataset.theme を設定しています。

src/layouts/MetadataSettings.astro
<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を明示的に指定しています。

src/pages/index.astro
const featuredWorkIds = ["2024/regina"];

個人サイトのトップページに出したい作品は、必ずしも最新作とは限りません。

なので、ここは自動化しすぎず、自分で選べるようにしました。

ついでに、IDの重複や存在しないID、下書き作品を指定した場合はビルド時に落とすようにしています。

src/pages/index.astro
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、個人ブログに向きすぎている件でした。

© 2026 Chimonakiko