Astro 実装テクニック
Astro で静的サイトを組む際の実装ノート。Content Collection のリレーション設計、View Transitions(client router)による画面遷移までを 1 ページに統合。
Astro Content Collection で関連記事 / hub ページを組む
Astro Content Collection で複数 collection を関連付け、hub ページや関連記事 aside を自動生成するパターン — glob loader / reference / cross-link / category タクソノミー / カテゴリ別 RSS / pagefind 検索の組み込み方を実装メモ化。
検証日: 2026-05-10
使用バージョン:
astro@6.3.x対象: Content Collection の最小構成は通った、wiki / docs サイトに発展させたい人
入門は別途(MDX 記事を 1 collection で並べる手前まで)。本稿は 複数 collection を関連付けて hub / 関連 aside / search を自動生成する 中級パターン。
1. 複数 collection と reference
src/content.config.ts で複数の collection を定義し、片方が他方を reference で参照する:
import { defineCollection, reference, z } from "astro:content";
import { glob } from "astro/loaders";
const articles = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/articles" }),
schema: z.object({
title: z.string(),
summary: z.string(),
category: z.string(),
// 別 collection の id を reference で持つ
relatedArticles: z.array(reference("articles")).default([]),
author: reference("authors").optional(),
}),
});
const authors = defineCollection({
loader: glob({ pattern: "**/*.json", base: "./src/content/authors" }),
schema: z.object({
name: z.string(),
bio: z.string(),
}),
});
export const collections = { articles, authors };
ポイント:
reference("articles")は collection 名の文字列(articlescollection のidのいずれかを許容)- schema の zod が拘束するので、存在しない id を frontmatter に書くと build エラー
- 配列で複数参照 = 関連記事リスト
2. 参照を追跡して related エントリを取得
getEntries(refs) / getEntry(ref) で reference の実体を遅延 fetch。getStaticPaths の中で呼ぶ:
---
import { getEntries, getCollection } from "astro:content";
const all = await getCollection("articles");
const entry = all.find((a) => a.id === Astro.params.slug)!;
// reference を追跡して実体を取得
const relatedEntries = await getEntries(entry.data.relatedArticles);
const author = entry.data.author ? await getEntry(entry.data.author) : null;
---
<aside>
<h3>関連記事</h3>
<ul>
{relatedEntries.map((r) => <li><a href={`/articles/${r.id}`}>{r.data.title}</a></li>)}
</ul>
</aside>
getEntries / getEntry で reference の実体を遅延 fetch。手動で全件 load + filter するより型安全 + パフォーマンス良い。
3. category タクソノミーの自動 hub
frontmatter に category を入れて、category 一覧ページを getStaticPaths で自動生成:
// src/lib/categories.ts
export const CATEGORIES = {
forms: { label: "フォーム", description: "..." },
visualization: { label: "可視化", description: "..." },
// ...
};
---
// src/pages/categories/[id].astro
import { getCollection } from "astro:content";
import { CATEGORIES } from "@/lib/categories";
export async function getStaticPaths() {
return Object.keys(CATEGORIES).map((id) => ({ params: { id } }));
}
const { id } = Astro.params;
const meta = CATEGORIES[id as keyof typeof CATEGORIES];
const articles = (await getCollection("articles", ({ data }) => data.category === id))
.sort((a, b) => a.data.title.localeCompare(b.data.title));
---
<h1>{meta.label}</h1>
<p>{meta.description}</p>
<ul>
{articles.map((a) => (
<li><a href={`/articles/${a.id}`}>{a.data.title}</a></li>
))}
</ul>
これで /categories/forms / /categories/visualization などのページが自動生成される。
4. 同じ category 内の他記事を aside で表示
記事ページの [...slug].astro で:
---
import { getCollection } from "astro:content";
const all = await getCollection("articles", ({ data }) => !data.draft);
const { entry } = Astro.props;
const sameCat = all
.filter((a) => a.data.category === entry.data.category && a.id !== entry.id)
.sort((a, b) => (a.data.library ?? "").localeCompare(b.data.library ?? ""));
---
<aside>
<h3>同じカテゴリ</h3>
<ul>
{sameCat.map((a) => <li><a href={`/articles/${a.id}`}>{a.data.title}</a></li>)}
</ul>
</aside>
reference を毎記事で書くのが手間な場合、category の自動グループ化で代替。explicit relation が必要な深掘り記事は relatedArticles で個別指定、という二段使いが現実的。
5. カテゴリ別 RSS を @astrojs/rss で生成
src/pages/<category>/rss.xml.ts を作って rss() に items を流す。getStaticPaths で category 別の endpoint を生成可能:
// src/pages/feed/[category].xml.ts
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import { CATEGORIES } from "@/lib/categories";
import type { APIContext } from "astro";
export async function getStaticPaths() {
return Object.keys(CATEGORIES).map((id) => ({ params: { category: id } }));
}
export async function GET(context: APIContext) {
const { category } = context.params;
const items = (await getCollection("articles", ({ data }) => data.category === category))
.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
return rss({
title: `tech-book-labs / ${CATEGORIES[category!].label}`,
description: CATEGORIES[category!].description,
site: context.site!,
items: items.map((i) => ({
title: i.data.title,
pubDate: i.data.publishedAt,
description: i.data.summary,
link: `/articles/${i.id}`,
})),
});
}
/feed/forms.xml / /feed/visualization.xml のような category 別フィード が生成される。
6. pagefind による静的全文検索
build 後の dist/ を pagefind が走査して、JS で動く静的検索インデックスを作る:
{
"scripts": {
"build": "astro build && pagefind --site dist"
},
"devDependencies": {
"pagefind": "^1.x"
}
}
検索 UI(クライアント側、~10KB):
<!-- src/components/Search.astro -->
<input id="q" placeholder="検索..." />
<div id="results"></div>
<script>
import { Pagefind } from "/pagefind/pagefind.js";
const pagefind = await Pagefind();
document.getElementById("q")!.addEventListener("input", async (e) => {
const q = (e.target as HTMLInputElement).value;
if (q.length < 2) return;
const search = await pagefind.search(q);
const data = await Promise.all(search.results.slice(0, 10).map((r) => r.data()));
document.getElementById("results")!.innerHTML = data.map((d) =>
`<a href="${d.url}">${d.meta.title}</a>`
).join("");
});
</script>
ポイント:
- build 時にインデックスを作る(runtime 不要、CDN だけで動く)
- bundle 影響は数十 KB(language-pack を絞れば軽い)
<body data-pagefind-body>を main 要素に付けて検索範囲を絞る
7. 双方向リンクの自動化(graph 抽出)
記事の本文中に [[other-article-id]] のような表記を許して、build 時にバックリンクを自動生成する手法もある。プラグイン例:
// astro.config.mjs
import { remarkWikilink } from "remark-wiki-link";
export default defineConfig({
markdown: {
remarkPlugins: [
[remarkWikilink, { hrefTemplate: (slug) => `/articles/${slug}` }],
],
},
});
build 後に MDX → MD ASTを解析 → 全 wikilink を JSON ファイルに集約すれば、各記事の 「ここに言及している記事」 aside を作れる。Obsidian 風 wiki の感覚。
つまずいたポイント
referenceの id 文字列が glob loader と一致しない:loader のpatternから拡張子を除いた path が id。frontmatter 側でその文字列を書く- 同じ collection を循環参照すると無限ループ:reference を辿る depth に上限を設ける(2 hop で十分なケースが多い)
relatedArticlesを全記事に手で書くのが大変:tag / category の 自動グルーピングで代替 + 手動 reference は本当に強い関連だけ- pagefind が docs を index しない:
<body data-pagefind-body>を<main>に移すか、data-pagefind-ignoreで除外指定 - build 時間が増える:pagefind は 1000 記事で +10 秒程度。CI でキャッシュすると軽い
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
Effective TypeScript(第2版) : 型システムの力を最大限に引き出す83項目
急速に普及が進んでいるTypeScriptの実用書! TypeScriptの実用書。TypeScript…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
かんたん TypeScript
本書は、「広く・正しく・新しく」をコンセプトにTypeScriptでプログラミングをはじめるにあたって基本的なことはすべて学習できる内容となっています。また、イラストによる図解方式で概念をやさしく解説している…
ゼロからわかる TypeScript入門
プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで
TypeScriptは、JavaScriptに静的型付けの機能を加えたオープンソースのプログラミング言語です。本書では、根幹となるJavaScr…
Astro View Transitions で MPA に SPA 風遷移を載せる
Astro 6 の <ClientRouter /> による View Transitions API 統合 — transition:name で要素を繋ぎ、transition:persist で state を維持、transition:animate で挙動を上書き、astro:before-swap でナビゲーションフックを取る実装パターンをまとめた実装メモ。
検証日: 2026-05-10
使用バージョン:
astro@6.3.x対象: 静的 / MPA な Astro サイトに、ページ遷移のチラつきや scroll リセットを防ぐ「SPA 風」体験を載せたい人
Astro 6 の <ClientRouter />(View Transitions API 統合)で、MPA(Multi-Page App)サイトに SPA 風のページ遷移を載せるパターン。transition:name で要素を繋ぎ、transition:persist で state を維持、astro:before-swap 等のナビゲーションフックを取る実装を整理します。
なぜ View Transitions
通常の MPA は ページ遷移時に white flash + 全要素が再描画 される。
Astro 6 の <ClientRouter /> を入れると:
- ページ遷移を fetch + DOM 差分置換 に切替(SPA 風)
- ブラウザの View Transitions API(or fallback)で要素を滑らかに繋ぐ
- 共通要素(
header/footer)を 再描画せずに維持 - scroll 位置や music player の state も保持できる
「重 SPA を作らずに UX だけ SPA 化」できるのが Astro の強み。
ナビゲーションのライフサイクル
<ClientRouter /> を入れると、internal link クリック時に以下の流れに切り替わる。各 phase で発火する astro:* イベントを hook して、ローディング表示や scroll 制御を差し込める。
実用ヒント:
astro:before-preparationで「読込中…」UI を出すと、遅い遷移でも体感が悪くないastro:after-swapは新 DOM 確定後に呼ばれるので、新 page の DOM クエリができるastro:page-loadは 新規訪問・遷移後の両方で呼ばれる(普通のDOMContentLoaded代わりに使える)
1. 最小導入
BaseLayout の <head> に <ClientRouter /> を 1 行入れるだけで、すべての internal link がナビゲーション制御下に入る:
---
import { ClientRouter } from "astro:transitions";
---
<html>
<head>
<ClientRouter />
<!-- 他の head タグ -->
</head>
<body>
<slot />
</body>
</html>
これだけで全 internal link がナビゲーション制御下に入る。追加の component を書く必要なし。
2. transition:name で要素を繋ぐ
「カード一覧 → 詳細ページ」で、同じ要素が動いて見えるようにする:
{articles.map((a) => (
<a href={`/articles/${a.id}`}>
<article transition:name={`card-${a.id}`}>
<h2 transition:name={`title-${a.id}`}>{a.title}</h2>
</article>
</a>
))}
<!-- 詳細側 src/pages/articles/[...slug].astro -->
<article transition:name={`card-${entry.id}`}>
<h1 transition:name={`title-${entry.id}`}>{entry.data.title}</h1>
<Content />
</article>
ポイント:
- 同じ
transition:nameを別ページの 2 要素に:遷移時に View Transitions API が「同じ要素」とみなして補完 - id をユニーク化:カード ID / 記事 slug を含める
- 入れ子で書ける:
title-{id}は別の transition、card-{id}の中の小要素として個別補完
3. transition:persist で state / DOM を保持
ヘッダの音楽プレイヤー、検索ボックスの入力中の値、SPA 内 state など、ページを跨いで生き残らせたい要素:
<header>
<audio controls transition:persist src="/bgm.mp3" />
<input transition:persist:props placeholder="検索..." />
</header>
ポイント:
transition:persist= DOM 要素自体を再利用(再 mount しない、内部 state 保持)transition:persist:props= props を流用(Astro Island の React state 保持)- キーが必要な場合:
transition:persist="player"のように名前を付ける
「ページ遷移しても再生中の audio が止まらない」「フォーム入力が消えない」のような MPA では難しかった UX が低コストで実現できる。
4. transition:animate でアニメ挙動を上書き
fade / slide / none などの組込み effect、または customTransition() で独自 keyframes も渡せる:
<aside transition:animate="slide"> <!-- スライド -->
<aside transition:animate="fade"> <!-- フェード -->
<aside transition:animate="initial"> <!-- ブラウザデフォルト -->
<aside transition:animate="none"> <!-- アニメなし -->
<!-- カスタム -->
<aside transition:animate={customAnim}>
カスタム定義:
const customAnim = {
forwards: {
old: { name: "fadeOut", duration: "0.2s", easing: "ease-out" },
new: { name: "slideIn", duration: "0.3s", easing: "ease-out" },
},
backwards: { /* 戻る時 */ },
};
forwards(進む)/backwards(戻る)で別々に定義可。@keyframes を CSS で書いて name で参照。
5. ナビゲーションのライフサイクルイベント
クライアント側 JS で遷移を hook できる:
<script>
document.addEventListener("astro:before-preparation", (e) => {
console.log("遷移開始:", e.from, e.to);
});
document.addEventListener("astro:before-swap", (e) => {
// DOM 差し替え直前。新しい document を加工できる
e.newDocument.body.dataset.theme = localStorage.getItem("theme") ?? "light";
});
document.addEventListener("astro:after-swap", () => {
console.log("DOM 差し替え完了、新ページ表示");
});
document.addEventListener("astro:page-load", () => {
console.log("初回 + 遷移後に発火、Analytics 投げ込む等");
});
</script>
| イベント | タイミング | 用途 |
|---|---|---|
astro:before-preparation | 遷移開始(fetch 前) | loading state |
astro:after-preparation | 新 document 取得完了 | 加工準備 |
astro:before-swap | DOM 差し替え直前 | テーマ / 動的属性の再注入 |
astro:after-swap | DOM 差し替え直後 | scroll 位置調整 / 再 hydrate |
astro:page-load | 初回 + 遷移後 | analytics / 計測 |
6. ナビゲーションを抑止 / プログラム移動
astro:before-preparation の event.preventDefault() で遷移をキャンセル、navigate() で JS 側からプログラム遷移を発火:
<!-- このリンクは ClientRouter を通さず通常 navigation -->
<a href="/external" data-astro-reload>外部風</a>
<!-- script から遷移 -->
<script>
import { navigate } from "astro:transitions/client";
navigate("/articles");
</script>
data-astro-reload を付けると 強制的に full reload。ログアウト後に session を完全クリアする時など。
7. 計測 / 分析タグの再発火
GA4 や GTM は通常 DOMContentLoaded で発火するが、<ClientRouter /> だと 2 ページ目以降は発火しない。astro:page-load で再発火が必要:
<script>
document.addEventListener("astro:page-load", () => {
if (window.gtag) {
window.gtag("event", "page_view", { page_path: location.pathname });
}
});
</script>
<head> の中で 1 回だけ書けばよい(<ClientRouter /> が頭の script を維持してくれる)。
8. fallback と feature detection
ブラウザが View Transitions API を持たない場合、Astro は fade fallback に自動切替。 特殊な処理は不要だが、要素ごとに挙動を変えたい時:
if (document.startViewTransition) {
// 対応ブラウザ
} else {
// fallback
}
サポート状況(2026-05 時点)= Chrome / Edge / Safari は対応、Firefox は 進行中。fallback は cross-browser でフェードのみ。
つまずいたポイント
<ClientRouter />は head に置く:body に置くと初回ロードで効かないtransition:nameのキー衝突:同一ページ内で別要素に同じ name を使うとエラー- input の
transition:persist:propsで値が戻る:Astro Island 内で controlled な input は state 保持されない、transition:persistのみだと OK prefersReducedMotion対応:OS 設定で reduced-motion を有効にしているユーザのために、@media (prefers-reduced-motion: reduce)で* { animation: none !important }を入れる- Sentry / Analytics の 2 重発火:遷移ごとに発火するので、初回ロードでは発火しない条件分岐を入れる
- 画像 / 動画 の re-fetch:
transition:persistで audio / video を生かす、それ以外はキャッシュに任せる
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
JavaScriptによるはじめてのアルゴリズム入門
Python と JavaScriptではじめるデータビジュアライゼーション
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…