studio haneya

このサイトのアーキテクチャについて

はじめに

このサイトは技術ブログ兼ポートフォリオサイトとして作成しています
メインはブログなのでSSGで構成して表示パフォーマンスを確保しつつ、可視化や機械学習デモのポートフォリオも置きたいので部分的にCSRにしたい、というのが要件になります

そこで、全体をAstroで構成してSSG主体として、デモページなど一部CSRが必要ななる部分をReactアイランドとして作成する方法で構築しました。Viteが静的サイトとしてビルドしてくれますので、それをCloudflare Pagesで配信しています。バックエンドがまったく存在しない構成なので管理も簡単です

この記事では技術選定理由やどのような構成にしたかを解説していきます


全体構成

astro.config.mjs ── Astro 6 + @astrojs/react + @astrojs/mdx + @astrojs/sitemap

├── src/pages/           ← ルーティング(ファイルベース)
│   ├── index.astro          トップページ
│   ├── about.astro          About
│   ├── contact.astro        Contact
│   ├── blog/
│   │   ├── index.astro      記事一覧
│   │   └── [...slug].astro  個別記事(動的ルート)
│   └── works/
│       ├── index.astro           作品一覧
│       ├── japan-choropleth.astro  コロプレス図
│       └── ml-demo.astro          物体検出デモ

├── src/content/blog/    ← Markdown/MDX 記事(Content Layer API)
├── src/components/      ← Astro コンポーネント + React アイランド
├── src/layouts/         ← BaseLayout(共通レイアウト)
├── src/styles/          ← global.css(デザイントークン)
├── src/data/            ← ビルド前 ETL で生成した静的 JSON
└── scripts/             ← Python ETL スクリプト

Astro選定理由

Astroを選定した理由

SSGブログだけであれば単純にSSGサイトを作れば良いので簡単ですが、今回は簡単なデモサイトを配置したいのでCSRが入ってきます

デモサイトだけSPAで別途作っても良いのですし、Next.jsやReactRouter v7などでSSG, CSRを使い分けてフルに静的ビルドするのでも良いのですが、今回はAstroを選択しています。 AstroであればIslands Architectureを使って、全体をAstroで書きながら一部コンポーネントをReactやVueなどで書くという事が出来ますので簡単です

また、AstroであればContent Layer APIによりmarkdownで書かれた記事をビルド時に読み込んでSSGすることができます。つまりヘッドレスCMSを別途用意する必要がありません。記事のバックアップもソースのバックアップもgitでまとめて管理できます。編集はエディタでやって、プレビューはnpm run devして見ることになりますが、実際のレイアウトで確認しながら書ける分だけ記事を書くときのエクスペリエンスはヘッドレスCMSより良いぐらいです

これによりバックエンドが要らなくなるので、HTMLを配信する方式でCloudflare Pagesだけでデプロイすることにしました

Islands Architecture

このサイトのほとんどのページは静的なHTMLで十分ですが、Worksページにはインタラクティブなコンポーネントがあります

それなりに複雑な状態管理が入るのでReactで書きたい、1ページ分だけ独立したSPAみたいに書きたい、となるとAstroのIslands Architectureは最高の選択肢です。AstroでSSGすると、静的ビルド対象にしているページは完全にJSがなくなるように静的ビルドしてくれますが、一方でIsland内のReactコンポーネントにはCSRする為のJSを配信するなんてことが出来ます

<!-- japan-choropleth.astro -->
<BaseLayout>
  <article class="exhibit">
    <p class="breadcrumb"><a href="/works/">← Works</a></p>

    <!-- ここだけが React アイランド。JS が配信される -->
    <ChoroplethMap client:visible />

    <!-- ここから下は静的 HTML。JS ゼロ -->
    <details class="how">
      <summary>仕組み(データと実装)</summary>
      ...
    </details>
  </article>
</BaseLayout>

Next.jsやRemixだとサイト全体がReactのランタイムを必要としてしまうので、ブログページしか表示していなくてもReactランタイムを読んでしまうのがモヤッとするポイントですが、Astroではヘッダー・フッター・テキスト部分は純粋なHTMLとして配信され、地図コンポーネントだけが独立したチャンクとして読み込まれます(実際のパフォーマンスにはほとんど影響しないので、開発者ツールを見なければ気付くこともないのですが、開発者としては気になってしまうところなので、自分で好きにできるサイトはユーザー要件と関係のない開発者エクスペリエンスまで含めて好きに選べるのが良いところでもあると思います)

ハイドレーション指示の使い分け

AstroはSSGが主目的のフレームワークですが、アイランドごとにある程度ハイドレーションのタイミング制御が可能です
このサイトでは2つの指示を使い分けています

指示挙動使用コンポーネント
client:visibleビューポートに入ったらハイドレーションコロプレス図、余寿命予測チャート
client:only="react"サーバレンダリングなし、クライアントのみ物体検出デモ

client:visible は IntersectionObserver を使って、コンポーネントが画面に表示されるまで JS の読み込みを遅延します。たとえばコロプレス図のバンドルは約 550KB ありますが、ページの HTML 表示はこれを待ちません

client:only="react" は SSR(サーバサイドレンダリング)自体をスキップします。物体検出デモは Canvas API や動的 import などブラウザ専用の API に依存しているため、サーバ側では意味のある HTML を生成できません。最初からクライアント専用と宣言することで、ビルド時のエラーを回避しています


ブログ: Content Layer API

glob ローダーによる記事管理

先ほど書いたように、ブログ記事はAstroのContent Layer APIで管理しています。src/content/blog/ 配下にMarkdownまたはMDXファイルで作成しておいて、glob() ローダーで収集してZodスキーマでバリデーションします。フロントエンドだけでバリデーションもできるの最高ですよね

// src/content.config.ts
const blog = defineCollection({
  loader: glob({
    pattern: ['**/*.{md,mdx}', '!_drafts/**'],
    base: './src/content/blog',
    generateId: ({ entry }) => {
      // "2026-06-01_site-architecture/index.md"
      //  → slug: "site-architecture"
      let name = parts.at(-1)!.replace(/\.(md|mdx)$/, '');
      if (name === 'index') name = parts.at(-2) ?? name;
      return name.replace(/^\d{4}-\d{2}(-\d{2})?_/, '');
    },
  }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    category: z.enum(BLOG_CATEGORIES).optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

ファイル名規約と URL の分離

記事のファイル配置は YYYY-MM-DD_スラッグ/index.md という命名規約にしています

src/content/blog/
├── 2026-06-01_site-architecture/index.md     ← この記事
├── 2026-06-10_work1_choropleth-architecture/index.md
└── 2026-06-17_ml-demo-architecture/index.md

当初はファイルシステムルーティングに準拠してフォルダ名をそのままパスに使っていたんですが、VScode上で記事の順番が分からなくなってしまうのでフォルダ名に日付を入れるようにしました。た、URLに日付を入れるのは嫌だったので日付をなくした文字列を記事のパスとしています。Astroで/src/pages/に配置してそのまま表示する方式だとファイルシステムルーティングされてそのままパスになりますが、Content Layer APIの場合は関数の戻り値をslugにできるので日付を削って /blog/site-architecture/ のようにしています

下書きの管理

_drafts/ ディレクトリに置いた記事は glob パターン !_drafts/** で除外されてビルドに含まれません。また、公開ディレクトリにあっても draft: true を設定すると、本番ビルドではスキップされ、開発サーバー(npm run devのとき)だけ表示されます。これにより書きかけの記事がちゃんとプレビューできますし、しばらく寝かせようと思ってる記事はプレビューにも出ない、という状態で管理できます

// blog/index.astro — 本番では draft を除外
const posts = await getCollection('blog', ({ data }) =>
  import.meta.env.DEV || !data.draft
);

前後ナビゲーション

個別記事ページ([...slug].astro)では、ビルド時に全記事を日付順にソートして前後の記事を props として渡しています

export async function getStaticPaths() {
  const posts = (await getCollection('blog', ...))
    .sort((a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf());
  return posts.map((post, i) => ({
    params: { slug: post.id },
    props: {
      post,
      prev: posts[i - 1] ?? null,
      next: posts[i + 1] ?? null,
    },
  }));
}

getStaticPathsで書くのはNext.jsと似ていますが、Astroの場合はgetStaticPropsがなくてデータもまとめて扱えます。Next.jsはハイドレーションをコントロールする為に分けられていますがAstroはSSG前提なのでシンプルになっています


レイアウトと CSS 設計

単一レイアウト + wide モード

レイアウトは1つだけで、画面幅で本文の最大幅を切り替えるだけにしています

<main class={wide ? 'wide' : ''}>
  <slot />
</main>
main     { max-width: 760px; }   /* ブログ記事(読みやすい行長) */
main.wide { max-width: 1080px; }  /* 作品ページ(可視化に横幅が必要) */

ブログ記事はテキスト中心なので 760px に収め、作品ページは地図やチャートに横幅が必要なので 1080px に広げています

CSS

今回は小規模なのでCSSフレームワークは使わず global.css にの CSS カスタムプロパティで設計しています

:root {
  --color-bg: #ffffff;
  --color-fg: #09090b;
  --color-accent: #ea580c;
  --color-border: #e4e4e7;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI",
    "Hiragino Sans", "Noto Sans CJK JP", Meiryo, sans-serif;
  /* ... */
}

このところTailwindで書くことが多かったので、たまには違う書き方をしようというとglobal.cssで書いてみました。規模が大きくなるとTailwindの方が書きやすいですが、これぐらいだと1か所にまとめて書けるメリットの方が大きくなると思います

ダークモード

ダークモードはprefers-color-schemeで自動切替するようにしました

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #09090b;
    --color-fg: #fafafa;
    --color-accent: #fb923c;
    /* ... */
  }
}

カスタムプロパティで色を管理しているので、ダークモードはトークンの値を上書きするだけで済みます

Sticky ヘッダーと Sticky フッター

ヘッダーはposition: stickyで画面上部に固定。フッターはflexboxのsticky footerで、コンテンツが短いページでも画面下部に配置されます

body {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}
main {
  flex: 1 0 auto;   /* main が残りの高さを吸収 */
}

まとめ

選定判断理由
フレームワークAstro 6(SSG)サーバ不要、Islands Architecture
インタラクティブ UIReact 19 + Islands必要な箇所だけ JS を配信
ブログ管理Content Layer API + globZod バリデーション、日付 slug 分離
CSSCSS カスタムプロパティ + スコープ付き styleこの規模で Tailwind は過剰
データビルド前 ETL + 静的 JSON外部 API 依存をランタイムから排除
デプロイCloudflare Pages静的ファイル配信、サーバ運用なし

設計の軸は「静的サイトとして配信し、インタラクティブ性が必要な箇所だけReactを島として載せる」ことです。ブログもデータパイプラインもビルド時に完結することで、ランタイムの外部依存をゼロにしています


お仕事募集しています
案件相談はX DM (@studio_haneya) まで


← 記事一覧に戻る