制作記

【エセ驚き屋】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を使っているのは、たとえばブログ一覧の検索・絞り込みや、プロフィールページの名刺ダイアログなど、状態管理やイベント処理が必要な部分だけです。

<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の型をちゃんと決められるからです。

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

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

© 2026 Chimonakiko