T-CREATOR

Astro の静的サイト生成(SSG)と動的ルーティング徹底解説

Astro の静的サイト生成(SSG)と動的ルーティング徹底解説

モダンな Web 開発において、パフォーマンスと開発体験の両立は重要な課題です。Astro は革新的なアプローチでこの課題を解決し、静的サイト生成の新たな可能性を提示しています。

本記事では、Astro の静的サイト生成(SSG)機能と動的ルーティングについて、基本概念から実装方法まで詳しく解説いたします。初心者の方でも理解できるよう、段階的に進めながら、実際のコードサンプルを交えて説明していきますね。

背景

静的サイト生成の重要性とメリット

静的サイト生成(SSG)は、ビルド時にすべてのページを事前に生成する手法です。この手法により、優れたパフォーマンスとセキュリティが実現できます。

従来の動的サイトでは、ユーザーがアクセスするたびにサーバーがページを生成していました。しかし、SSG では事前に生成されたファイルを配信するため、表示速度が圧倒的に向上します。

主なメリットは以下の通りです。

#メリット詳細
1高速表示事前生成されたファイルを配信するため、レスポンス時間が短縮
2SEO 最適化検索エンジンがコンテンツを正確にクロール可能
3セキュリティサーバーサイドの処理が最小限でセキュリティリスクが低減
4コスト削減CDN での配信により、サーバー負荷とコストが削減

Astro の特徴と他フレームワークとの違い

Astro は「Islands Architecture」という独自のアプローチを採用しています。これは、静的な HTML を基盤とし、必要な部分にのみインタラクティブなコンポーネントを配置する手法です。

以下の図で、Astro のアーキテクチャを視覚的に理解できます。

mermaidflowchart TD
    html[静的 HTML ベース] --> island1[インタラクティブ島 1<br/>React コンポーネント]
    html --> island2[インタラクティブ島 2<br/>Vue コンポーネント]
    html --> island3[インタラクティブ島 3<br/>Svelte コンポーネント]
    html --> static[静的コンテンツ部分]
    
    island1 --> hydrate1[必要時のみハイドレーション]
    island2 --> hydrate2[必要時のみハイドレーション]
    island3 --> hydrate3[必要時のみハイドレーション]

この図が示すように、Astro では各フレームワークのコンポーネントを必要な箇所にのみ配置できます。

他の主要フレームワークとの比較を見てみましょう。

#フレームワークアプローチJavaScript バンドルサイズ
1Next.jsフルスタックフレームワーク中〜大
2Nuxt.jsフルスタックフレームワーク中〜大
3GatsbyGraphQL ベース SSG
4AstroIslands Architecture小〜無し

SSG が注目される理由とパフォーマンス優位性

現代の Web では、Core Web Vitals などのパフォーマンス指標が SEO に直接影響します。SSG はこれらの指標を改善する最も効果的な手法の一つです。

パフォーマンス優位性を具体的な数値で見てみましょう。

mermaidgraph LR
    A[ユーザーアクセス] --> B[CDN から配信]
    B --> C[HTML ファイル取得]
    C --> D[ページ表示完了]
    
    B2[従来の動的サイト] --> C2[サーバー処理]
    C2 --> D2[データベース接続]
    D2 --> E2[ページ生成]
    E2 --> F2[ページ表示完了]
    
    style A fill:#e1f5fe
    style D fill:#c8e6c9
    style F2 fill:#ffcdd2

SSG では、ユーザーアクセスから表示完了まで数百ミリ秒で完了するのに対し、従来の動的サイトでは数秒かかることがあります。

課題

従来の SSG ツールの制約

従来の SSG ツールには、いくつかの制約がありました。特に以下の点が開発者にとって課題となっていました。

フレームワークの選択肢が限定されることが大きな問題でした。Jekyll なら Ruby、Hugo なら Go といったように、特定の技術スタックに縛られる制約がありました。

また、インタラクティブな機能を追加する際の複雑性も課題の一つです。静的サイトに動的な要素を組み込むには、別途 JavaScript を記述し、適切にハイドレーションを管理する必要がありました。

動的ルーティングの複雑性

動的ルーティングは、URL パラメータに基づいて異なるページを生成する機能です。しかし、従来のツールでは実装が複雑でした。

以下のような課題が存在していました。

mermaidflowchart TD
    A[動的ルーティング実装] --> B[ページ生成パターン定義]
    B --> C[データソース設定]
    C --> D[ビルド時実行]
    D --> E{成功?}
    E -->|No| F[エラー発生<br/>デバッグ困難]
    E -->|Yes| G[静的ページ生成完了]
    
    F --> H[設定見直し]
    H --> B

特にデータソースが複数ある場合や、ネストしたルーティングが必要な場合、設定ファイルが複雑になり、メンテナンスが困難でした。

ビルド時間と開発体験のトレードオフ

大規模なサイトでは、ビルド時間が大幅に増加する問題がありました。数千ページのサイトでは、フルビルドに数十分かかることも珍しくありません。

開発時の課題として以下が挙げられます。

#課題影響従来の解決策
1長いビルド時間開発速度低下インクリメンタルビルド
2ホットリロード制限デバッグ効率悪化開発用サーバー併用
3プレビュー困難品質確認遅延ステージング環境構築

解決策

Astro の SSG 仕組みと特徴

Astro は前述の課題を革新的なアプローチで解決しています。最も重要な特徴は「ゼロ JavaScript デフォルト」です。

Astro の基本的な仕組みを理解するため、以下のコードを見てみましょう。

astro---
// コンポーネントスクリプト(ビルド時実行)
export interface Props {
  title: string;
  content: string;
}

const { title, content } = Astro.props;
---

<!-- テンプレート部分(HTML 生成) -->
<html>
  <head>
    <title>{title}</title>
  </head>
  <body>
    <h1>{title}</h1>
    <div set:html={content} />
  </body>
</html>

このコードは、ビルド時に実行され、純粋な HTML ファイルを生成します。JavaScript は一切含まれません。

Astro の SSG プロセスは以下のように動作します。

mermaidsequenceDiagram
    participant Dev as 開発者
    participant Astro as Astro ビルダー
    participant FS as ファイルシステム
    participant CDN as CDN

    Dev->>Astro: ビルド実行
    Astro->>FS: .astro ファイル読み込み
    Astro->>Astro: コンポーネントスクリプト実行
    Astro->>Astro: HTML 生成
    Astro->>FS: 静的ファイル出力
    FS->>CDN: ファイルデプロイ
    CDN-->>Dev: 配信完了

この流れにより、最終的な成果物は最適化された静的ファイルのみとなります。

動的ルーティングの実装方法

Astro では、getStaticPaths 関数を使って動的ルーティングを実装します。この機能により、複雑なルーティングパターンも簡潔に記述できます。

基本的な動的ルーティングの実装例を見てみましょう。

typescript// src/pages/blog/[slug].astro のフロントマター部分
export async function getStaticPaths() {
  const posts = await fetch('https://api.example.com/posts')
    .then(response => response.json());

  return posts.map((post: any) => ({
    params: { slug: post.slug },
    props: { post }
  }));
}

この関数は、ビルド時に実行され、各記事に対応する静的ページを生成します。

より複雑なパターンとして、ネストしたルーティングも実装できます。

typescript// src/pages/category/[category]/[slug].astro
export async function getStaticPaths() {
  const categories = await getCategories();
  const paths = [];

  for (const category of categories) {
    const posts = await getPostsByCategory(category.id);
    
    for (const post of posts) {
      paths.push({
        params: { 
          category: category.slug, 
          slug: post.slug 
        },
        props: { post, category }
      });
    }
  }

  return paths;
}

この実装により、​/​category​/​tech​/​astro-tutorial のような URL 構造を持つページが生成されます。

パフォーマンス最適化技術

Astro には、パフォーマンスを最大化するための様々な最適化技術が組み込まれています。

主要な最適化技術を以下にまとめました。

#最適化技術効果実装方法
1自動的な CSS 最適化ファイルサイズ削減ビルド時自動実行
2画像最適化読み込み速度向上@astrojs​/​image 利用
3コード分割初期読み込み時間短縮Dynamic import 活用
4プリロード最適化ナビゲーション高速化astro:prefetch 使用

画像最適化の実装例を見てみましょう。

astro---
import { Image } from '@astrojs/image/components';
---

<!-- 自動的にWebP変換、レスポンシブ対応 -->
<Image
  src="/images/hero.jpg"
  alt="ヒーロー画像"
  width={800}
  height={400}
  format="webp"
  quality={80}
/>

この設定により、ブラウザに応じて最適な画像フォーマットが自動選択されます。

具体例

基本的な静的ページ生成

まず、最もシンプルな静的ページ生成から始めましょう。Astro プロジェクトのセットアップから説明いたします。

プロジェクトの初期化コマンドは以下の通りです。

bash# Astroプロジェクトの作成
yarn create astro@latest my-astro-site

# 依存関係のインストール
cd my-astro-site
yarn install

基本的なページコンポーネントを作成してみましょう。

astro---
// src/pages/index.astro
const pageTitle = "Astro SSG サンプルサイト";
const description = "静的サイト生成の実践例";
---

<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{pageTitle}</title>
    <meta name="description" content={description} />
  </head>
  <body>
    <header>
      <h1>{pageTitle}</h1>
      <p>{description}</p>
    </header>
    
    <main>
      <section>
        <h2>Astro の特徴</h2>
        <ul>
          <li>ゼロ JavaScript デフォルト</li>
          <li>Islands Architecture</li>
          <li>優れたパフォーマンス</li>
        </ul>
      </section>
    </main>
  </body>
</html>

このコンポーネントは、ビルド時に純粋な HTML ファイルに変換されます。

レイアウトコンポーネントを作成して、再利用性を高めてみましょう。

astro---
// src/layouts/BaseLayout.astro
export interface Props {
  title: string;
  description?: string;
}

const { title, description = "Astro で構築されたサイト" } = Astro.props;
---

<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{title}</title>
    <meta name="description" content={description} />
    <link rel="stylesheet" href="/styles/global.css" />
  </head>
  <body>
    <slot />
  </body>
</html>

このレイアウトを使って、ページを簡潔に記述できます。

astro---
// src/pages/about.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---

<BaseLayout 
  title="概要ページ" 
  description="Astro SSG について詳しく説明します"
>
  <main>
    <h1>Astro について</h1>
    <p>Astro は革新的な静的サイトジェネレーターです。</p>
  </main>
</BaseLayout>

getStaticPaths を使った動的ルーティング

動的ルーティングは、URL パラメータに基づいて複数のページを生成する機能です。ブログサイトや商品ページなど、似たような構造のページを大量に生成する際に威力を発揮します。

まず、基本的な動的ルーティングを実装してみましょう。

astro---
// src/pages/posts/[id].astro
import BaseLayout from '../../layouts/BaseLayout.astro';

export async function getStaticPaths() {
  // ビルド時に投稿データを取得
  const posts = [
    { id: 1, title: "Astro 入門", content: "Astro の基本概念について" },
    { id: 2, title: "SSG 活用法", content: "静的サイト生成の実践的な使い方" },
    { id: 3, title: "パフォーマンス最適化", content: "Astro でのパフォーマンス向上技術" }
  ];

  return posts.map(post => ({
    params: { id: post.id.toString() },
    props: { post }
  }));
}

const { post } = Astro.props;
---

<BaseLayout title={post.title}>
  <article>
    <h1>{post.title}</h1>
    <div>
      <p>{post.content}</p>
    </div>
  </article>
</BaseLayout>

この実装により、​/​posts​/​1​/​posts​/​2​/​posts​/​3 の 3 つのページが自動生成されます。

外部 API からデータを取得する場合の実装例も見てみましょう。

typescript// src/pages/products/[slug].astro のフロントマター部分
export async function getStaticPaths() {
  try {
    // 外部APIから商品データを取得
    const response = await fetch('https://api.example.com/products');
    const products = await response.json();

    return products.map((product: any) => ({
      params: { slug: product.slug },
      props: { 
        product,
        // 関連商品も一緒に取得
        relatedProducts: products
          .filter((p: any) => p.category === product.category && p.id !== product.id)
          .slice(0, 3)
      }
    }));
  } catch (error) {
    console.error('商品データの取得に失敗しました:', error);
    return [];
  }
}

エラーハンドリングも含めることで、ビルド時のトラブルを防げます。

複数のパラメータを持つ動的ルーティングも実装できます。

typescript// src/pages/blog/[year]/[month]/[slug].astro
export async function getStaticPaths() {
  const posts = await getBlogPosts();
  
  return posts.map(post => {
    const date = new Date(post.publishedAt);
    const year = date.getFullYear().toString();
    const month = (date.getMonth() + 1).toString().padStart(2, '0');
    
    return {
      params: { 
        year, 
        month, 
        slug: post.slug 
      },
      props: { post }
    };
  });
}

この設定により、​/​blog​/​2024​/​03​/​astro-tutorial のような URL 構造を実現できます。

API との連携実装

Astro では、ビルド時に外部 API と連携してデータを取得し、静的ページに埋め込むことができます。これにより、動的なコンテンツを静的サイトで表示できます。

まず、基本的な API 連携の実装を見てみましょう。

astro---
// src/pages/news.astro
import BaseLayout from '../layouts/BaseLayout.astro';

// ビルド時にニュースデータを取得
async function getNewsData() {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts');
    const posts = await response.json();
    return posts.slice(0, 10); // 最新10件を取得
  } catch (error) {
    console.error('ニュースデータの取得に失敗:', error);
    return [];
  }
}

const newsItems = await getNewsData();
---

<BaseLayout title="最新ニュース">
  <main>
    <h1>最新ニュース</h1>
    <div class="news-list">
      {newsItems.map(item => (
        <article class="news-item">
          <h2>{item.title}</h2>
          <p>{item.body.substring(0, 100)}...</p>
        </article>
      ))}
    </div>
  </main>
</BaseLayout>

<style>
  .news-list {
    display: grid;
    gap: 1rem;
    margin-top: 2rem;
  }
  
  .news-item {
    padding: 1rem;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
  }
</style>

より複雑な例として、認証が必要な API との連携も実装できます。

typescript// src/utils/api.ts
interface ApiResponse<T> {
  data: T;
  status: 'success' | 'error';
  message?: string;
}

export async function fetchWithAuth<T>(
  url: string, 
  apiKey: string
): Promise<ApiResponse<T>> {
  try {
    const response = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      }
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data = await response.json();
    return { data, status: 'success' };
  } catch (error) {
    console.error('API request failed:', error);
    return { 
      data: null as T, 
      status: 'error', 
      message: error instanceof Error ? error.message : 'Unknown error'
    };
  }
}

この関数を使って、安全に外部 API と連携できます。

astro---
// src/pages/dashboard.astro
import BaseLayout from '../layouts/BaseLayout.astro';
import { fetchWithAuth } from '../utils/api.ts';

const API_KEY = import.meta.env.API_KEY;
const apiResponse = await fetchWithAuth('https://api.example.com/dashboard', API_KEY);

const dashboardData = apiResponse.status === 'success' ? apiResponse.data : null;
---

<BaseLayout title="ダッシュボード">
  {dashboardData ? (
    <main>
      <h1>ダッシュボード</h1>
      <!-- ダッシュボードコンテンツ -->
    </main>
  ) : (
    <main>
      <h1>データの取得に失敗しました</h1>
      <p>しばらく時間をおいてから再度お試しください。</p>
    </main>
  )}
</BaseLayout>

環境変数を使って API キーを安全に管理することが重要です。

ブログサイト構築例

実際のブログサイトを構築する例を通して、Astro の実用的な活用方法を学びましょう。

まず、ブログ記事のデータ構造を定義します。

typescript// src/types/blog.ts
export interface BlogPost {
  id: string;
  title: string;
  slug: string;
  excerpt: string;
  content: string;
  publishedAt: string;
  updatedAt: string;
  author: {
    name: string;
    avatar: string;
  };
  tags: string[];
  coverImage?: string;
}

export interface BlogCategory {
  id: string;
  name: string;
  slug: string;
  description: string;
}

ブログ記事の一覧ページを作成しましょう。

astro---
// src/pages/blog/index.astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import type { BlogPost } from '../../types/blog.ts';

// マークダウンファイルから記事を取得
const posts = await Astro.glob<BlogPost>('../../../content/blog/*.md');

// 公開日でソート
const sortedPosts = posts
  .filter(post => post.frontmatter.publishedAt)
  .sort((a, b) => 
    new Date(b.frontmatter.publishedAt).getTime() - 
    new Date(a.frontmatter.publishedAt).getTime()
  );
---

<BaseLayout title="ブログ記事一覧">
  <main>
    <h1>ブログ記事一覧</h1>
    
    <div class="post-grid">
      {sortedPosts.map(post => (
        <article class="post-card">
          {post.frontmatter.coverImage && (
            <img 
              src={post.frontmatter.coverImage} 
              alt={post.frontmatter.title}
              loading="lazy"
            />
          )}
          
          <div class="post-content">
            <h2>
              <a href={`/blog/${post.frontmatter.slug}`}>
                {post.frontmatter.title}
              </a>
            </h2>
            
            <p class="excerpt">{post.frontmatter.excerpt}</p>
            
            <div class="post-meta">
              <time datetime={post.frontmatter.publishedAt}>
                {new Date(post.frontmatter.publishedAt).toLocaleDateString('ja-JP')}
              </time>
              
              <div class="tags">
                {post.frontmatter.tags.map(tag => (
                  <span class="tag">{tag}</span>
                ))}
              </div>
            </div>
          </div>
        </article>
      ))}
    </div>
  </main>
</BaseLayout>

<style>
  .post-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 2rem;
    margin-top: 2rem;
  }
  
  .post-card {
    border: 1px solid #e0e0e0;
    border-radius: 12px;
    overflow: hidden;
    transition: transform 0.2s ease, box-shadow 0.2s ease;
  }
  
  .post-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  }
  
  .post-content {
    padding: 1.5rem;
  }
  
  .post-meta {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 1rem;
    font-size: 0.875rem;
    color: #666;
  }
  
  .tags {
    display: flex;
    gap: 0.5rem;
  }
  
  .tag {
    background: #f0f0f0;
    padding: 0.25rem 0.5rem;
    border-radius: 4px;
    font-size: 0.75rem;
  }
</style>

個別の記事ページも実装しましょう。

astro---
// src/pages/blog/[slug].astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import type { BlogPost } from '../../types/blog.ts';

export async function getStaticPaths() {
  const posts = await Astro.glob<BlogPost>('../../../content/blog/*.md');
  
  return posts.map(post => ({
    params: { slug: post.frontmatter.slug },
    props: { post }
  }));
}

const { post } = Astro.props;
const { Content } = post;
---

<BaseLayout 
  title={post.frontmatter.title}
  description={post.frontmatter.excerpt}
>
  <article class="blog-post">
    <header class="post-header">
      {post.frontmatter.coverImage && (
        <img 
          src={post.frontmatter.coverImage} 
          alt={post.frontmatter.title}
          class="cover-image"
        />
      )}
      
      <h1>{post.frontmatter.title}</h1>
      
      <div class="post-meta">
        <div class="author">
          <img 
            src={post.frontmatter.author.avatar} 
            alt={post.frontmatter.author.name}
            class="author-avatar"
          />
          <span>{post.frontmatter.author.name}</span>
        </div>
        
        <time datetime={post.frontmatter.publishedAt}>
          {new Date(post.frontmatter.publishedAt).toLocaleDateString('ja-JP')}
        </time>
      </div>
    </header>
    
    <div class="post-content">
      <Content />
    </div>
    
    <footer class="post-footer">
      <div class="tags">
        {post.frontmatter.tags.map(tag => (
          <span class="tag">{tag}</span>
        ))}
      </div>
    </footer>
  </article>
</BaseLayout>

<style>
  .blog-post {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
  }
  
  .cover-image {
    width: 100%;
    height: 300px;
    object-fit: cover;
    border-radius: 12px;
    margin-bottom: 2rem;
  }
  
  .post-header h1 {
    font-size: 2.5rem;
    margin-bottom: 1rem;
    line-height: 1.2;
  }
  
  .post-meta {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 2rem;
    padding-bottom: 1rem;
    border-bottom: 1px solid #e0e0e0;
  }
  
  .author {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }
  
  .author-avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
  }
  
  .post-content {
    line-height: 1.7;
    margin-bottom: 2rem;
  }
  
  .post-content h2 {
    margin-top: 2rem;
    margin-bottom: 1rem;
  }
  
  .post-content p {
    margin-bottom: 1rem;
  }
  
  .tags {
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
  }
  
  .tag {
    background: #f0f0f0;
    padding: 0.5rem 1rem;
    border-radius: 20px;
    font-size: 0.875rem;
  }
</style>

この実装により、プロフェッショナルなブログサイトが完成します。マークダウンファイルからコンテンツを読み込み、美しいレイアウトで表示できますね。

まとめ

Astro の静的サイト生成と動的ルーティングについて、基本概念から実践的な実装まで詳しく解説いたしました。

学習ポイントの整理

本記事で学んだ重要なポイントを整理しましょう。

Astro の核心的特徴

  • ゼロ JavaScript デフォルトによる高速表示
  • Islands Architecture による最適なハイドレーション
  • 複数フレームワークの統合的な活用

動的ルーティングの実装

  • getStaticPaths 関数による柔軟なページ生成
  • 外部 API との連携による動的コンテンツの静的化
  • エラーハンドリングを含む堅牢な実装

パフォーマンス最適化

  • 自動的な CSS・JavaScript の最適化
  • 画像の最適化とレスポンシブ対応
  • 効率的なビルドプロセス

次のステップ

Astro をさらに活用するための学習の道筋をご提案いたします。

初級から中級へ

  1. コンポーネントの再利用性向上
  2. TypeScript の積極的な活用
  3. CSS フレームワークとの統合

中級から上級へ

  1. カスタムインテグレーションの開発
  2. 大規模サイトのビルド最適化
  3. サーバーサイドレンダリング(SSR)との使い分け

実践的なプロジェクト

  • 企業サイトのリニューアル
  • E コマースサイトの商品ページ生成
  • 多言語対応サイトの構築

Astro は現代の Web 開発において、パフォーマンスと開発体験を両立する優れたソリューションです。本記事で学んだ内容を基に、ぜひ実際のプロジェクトで活用してみてください。

継続的な学習により、より効率的で高品質な Web サイト開発が実現できるでしょう。

関連リンク

公式ドキュメント

学習リソース

技術資料

関連ツール