【Astro】CMS の API と連携してブログ記事を表示できるようにする手順（SDK 無しver.）

## 概要
Astro で構築した Web サイトで、外部 API から取得したデータを活用する一例として、ブログ記事での利用方法を記載します。

イメージは、CMS やヘッドレス CMS の API から、タイトルや記事本文や画像の URL を取得し、それを利用して画面に表示する処理の実装です。

CMS 側がサイト構築に利用できる専用の SDK を用意している場合、これを使うかどうかで書き方が変わるのですが、
今回は特定の CMS に依存せずにピュアな JavaScript 処理のみで **[Fetch API](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API)** を使って実装する方法を紹介します。

SDK を使用する方法も別記事にまとめていますので、microCMS を利用している場合は下記をご覧ください。

https://qiita.com/to3izo/items/65b4ac2fdb49def9bf82

### 前提
- Astro の基本的な書き方を知っている前提として記載していきます
- CMS 側の作業手順は説明しません。あらかじめ表示用の記事が２〜３つくられている前提で進めます
  - API のレスポンス形式も CMS 側のサンプルを使用しますので、適宜読み替えてください
- 構築したブログをデプロイする手順（レンタルサーバなり Firebase なり Vercel なりにアップロードする手順）も説明しません

また、今回利用する CMS は **「[microCMS](https://microcms.io/)」** という日本産のヘッドレス CMS です。microCMS は、API 利用が簡単にできる様に JavaScript 用の SDK （**microcms-js-sdk**）を用意していますが、**今回はそれを使用しません**。

microCMS には「API プレビュー」機能があり、どのようなレスポンスが返るか確認できる機能があります。

今回 API で取得する、記事１つ分のレスポンスデータは、以下です。

```json:ブログ１記事のデータ
{
    "id": "b49k0v7g6",
    "createdAt": "2024-02-10T14:03:15.824Z",
    "updatedAt": "2024-02-10T14:03:15.824Z",
    "publishedAt": "2024-02-10T14:03:15.824Z",
    "revisedAt": "2024-02-10T14:03:15.824Z",
    "title": "（サンプル）microCMS＋Astro でブログサイトを構築する",
    "content": "<p>最近話題の「ヘッドレスCMS」を使ってブログを構築してみようと思います。</p><p>今回は、国産ヘッドレスCMSの「microCMS」と、静的サイトジェネレータの「Astro」で簡単なブログサイトを作ってみました。</p>",
    "eyecatch": {
        "url": "https://images.microcms-assets.io/assets/54e262a32b7048ccbc8dfcd1e4ff30ad/409b8ff8a331415f8251601ab5587685/blog-template.png",
        "height": 630,
        "width": 1200
    },
    "category": {
        "id": "atelo585eo88",
        "createdAt": "2024-02-10T07:49:58.078Z",
        "updatedAt": "2024-02-10T07:49:58.078Z",
        "publishedAt": "2024-02-10T07:49:58.078Z",
        "revisedAt": "2024-02-10T07:49:58.078Z",
        "name": "テクノロジー"
    }
}
```

また、上記の他に、HTML のタグたくさん使った、画像（img タグ）も含まれた長めの記事を用意しました。そちらも表示できるか確認していきます。

また、記事一覧取得時の API のレスポンスは以下です。
```json-doc:ブログ記事一覧のデータ
{
  "contents": [
    {
      // 上記の１記事分と同じデータ内容
      "id": "b49k0v7g6",
        :
    },
    {
      // 上記の１記事分と同じデータ内容
      "id": "rytmup5w4ntx",
        :
    }
  ],
"totalCount": 2,
"offset": 0,
"limit": 10
}
```

## 手順
「記事一覧」画面と「記事詳細」画面を構築し、それぞれで一覧取得用の API と詳細取得用の API を使う様に実装していきます。

### 環境変数の設定
まず、環境変数を設定します。`.package.json` がある階層に `.env` がファイルを用意し、以下の様に値を設定しておきます。

```bash:.env
MICROCMS_API_URL=<YOUR_API_URL>
MICROCMS_API_KEY=<API_KEY>
```

`<YOUR_API_URL>` には、自分の記事の API の基本 URL 部分となる `https://xxxxx.microcms.io` をセットします。
`<API_KEY>` には、管理画面から確認できる API キーをセットしましょう。

### 画面の作成
次に、一覧画面と詳細画面となるページを用意します。

```
src/
  └ pages/
     └ articles
        ├ [...slug].astro
        └ index.astro
```

「記事」を意味する **「articles」** ディレクトリを作成し、一覧画面として `index.astro`、記事ID毎に表示される詳細画面として `[...slug].astro` を用意しました。詳細画面は、記事 ID 毎の動的ルーティングなので、`[変数].astro` としました。

中身は、とりあえず Astro で画面が表示できる最低限の画面を書きます。


**一覧画面**
```tsx:src/pages/articles/index.astro
---
import "../../styles/global.css";
---
<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="description" content="Astro description" />
  <meta name="viewport" content="width=device-width" />
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
  <meta name="generator" content={Astro.generator} />
  <title>Demo Blog | 一覧</title>
</head>
<body>
  <main>
    <div>
      一覧画面
    </div>
  </main>
</body>
</html>
```

私は、pages ディレクトリと同じ階層に `styles/global.css` を用意して適当な css を import で読み込んでますが、このファイルで `<style>` タグで書いたりすれば不要です。

**詳細画面**
```tsx:src/pages/articles/[...slug].astro
---
import "../../styles/global.css";

export function getStaticPaths() {
  return [
    { params: { slug: 1 } },
    { params: { slug: 2 } },
    { params: { slug: 3 } },
  ];
}
---
<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="description" content="Astro description" />
  <meta name="viewport" content="width=device-width" />
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
  <meta name="generator" content={Astro.generator} />
  <title>Demo Blog | 詳細</title>
</head>
<body>
  <main>
    <div>
      詳細画面
    </div>
  </main>
</body>
</html>
```

動的ルーディングを **静的（SSG）モード** で行うので、`getStaticPaths()` メソッドを使って、画面遷移ページを適当に定義しておきます。

Astro では、基本的に静的なサイトとして生成するのですが、`getStaticPaths()` を使うことで全てのページをビルド時に生成しておくことができます。

`params` の中にオブジェクトとして、変数を利用した詳細画面用のファイル名（`[...slug]` 部分）に値を合わせる必要があります。

実際にブラウザで表示確認してみます。

**一覧画面** `http://localhost:4321/articles`

👉 （すみません、画像貼り忘れました💦）


**詳細画面** `http://localhost:4321/articles/1`

![demo_detail.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/787582/04f3b77a-3ff4-f176-b03d-0b51658f3437.png)

無事に表示ができました。
尚、定義していないルーティングページ（例： `/articles/4` とか）だと、静的ページが生成されていないため、"404"（ページが見つからない）エラー画面が表示されます。

### API 実行処理を作る
`libs` というディレクトリを `src` 直下に作成して、`microcms.ts` というファイルを作り、API を実行するライブラリとして処理を実装します。尚、今回は、TypeScript で書きます。
（名前は `libs` でなくても `library` でも `service` でもなんでも大丈夫です）

ここに、環境変数から値を取得して API に利用する値を定義します。

```ts:src/libs/microcms.ts
// API に使用する値の定義
if (
  import.meta.env["MICROCMS_API_URL"] === undefined ||
  import.meta.env["MICROCMS_API_KEY"] === undefined
) {
  throw new Error(
    "Please set environment variables: MICROCMS_API_URL and MICROCMS_API_KEY"
  );
}

export const microcmsUrl = import.meta.env["MICROCMS_API_URL"];
export const microcmsApiKey = import.meta.env["MICROCMS_API_KEY"];
export const microcmsApiPrefix = "/api/v1";

// 型定義
export type Article = {
  id: string;
  createdAt: Date;
  updatedAt: Date;
  publishedAt: Date;
  revisedAt: Date;
  title: string;
  content: string;
  eyecatch: {
    url: string;
    height: number;
    width: number;
  }
  category: {
    id: string;
    createdAt: Date;
    updatedAt: Date;
    publishedAt: Date;
    revisedAt: Date;
    name: string;
  }
}

// API 呼び出し
const apiHeaders = new Headers();
apiHeaders.append("X-MICROCMS-API-KEY", microcmsApiKey);

// CMS から API で記事データを取得する (一覧)
export const getArticles = async () => {
  const response = await fetch(`${microcmsUrl}${microcmsApiPrefix}/articles`, {
    headers: apiHeaders,
  });
  const responseJson = await response.json();

  // サンプルのレスポンスデータ定義に従って "contents" の内容を取得する
  return responseJson.contents;
};

// CMS から API で記事データを取得する (詳細)
export const getArticleDetail = async (id: string) => {
  const response = await fetch(`${microcmsUrl}${microcmsApiPrefix}/articles/${id}`, {
    headers: apiHeaders,
  });

  return await response.json();
};
```

Astro は中で「Vite」が動いてるのですが、Vite では、環境変数は `process.env` の代わりに、ES2020 で追加された `import.meta` 機能を使用した `import.meta.env` を使用します。

また、型定義は同じ場所に記載してますが、お好みで別ファイルに分けても良いと思います。

### 画面で API 処理を呼ぶ
.astro ファイルの `---` 領域の中にサーバサイド処理を書けるので、そこに先ほど実装した記事取得処理を追加します。
また、取得した記事をループ処理で画面に表示する処理も記載します。

本来は、適切なタグを選んで、スタイル整えて `<ul>` や `<li>` で並べるべきですが、今回はわかりやすく適当に `<div>` タグで羅列します。

**一覧画面**
```diff_tsx:src/pages/articles/index.astro
 ---
 import "../../styles/global.css";
+// 記事一覧を取得
+import { getArticles } from "../../libs/microcms";
+import type { Article } from "../../libs/microcms";

+const articles: Article[] = await getArticles();
 ---
 <!doctype html>
 <html lang="ja">
   <meta charset="UTF-8" />
   <meta name="description" content="Astro description" />
   <meta name="viewport" content="width=device-width" />
   <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <meta name="generator" content={Astro.generator} />
     <title>Demo Blog | 一覧</title>
   </head>
   <body>
     <main>
-      一覧画面

+      {
+        // 記事一覧をループして表示
+        articles.map((article) => (
+          <div>
+            <a href={`/articles/${article.id}/`}>
+              <img
+                width={article.eyecatch.width}
+                height={article.eyecatch.height}
+                src={article.eyecatch.url}
+                alt={`img_${article.title}/`}
+              />
+              <h4 class="title">{article.title}</h4>
+            </a>
+          </div>
+        ))
+      }
     </main>
   </body>
 </html>
```

**詳細画面**
```diff_tsx:src/pages/articles/[...slug].astro
 ---
 import "../../styles/global.css";
+import { getArticles, getArticleDetail } from "../../libs/microcms";
+import type { Article } from "../../libs/microcms";

 export async function getStaticPaths() {
-  return [
-    { params: { slug: 1 } },
-    { params: { slug: 2 } },
-    { params: { slug: 3 } },
-  ];
 
+	// 記事ページの全パスを設定
+	const articles: Article[] = await getArticles();
+
+	return articles.map((article) => ({
+		params: { slug: article.id },
+	}));
 }

+// 設定したパスのパラメータから 記事 ID がセットされた変数を取得
+const { slug } = Astro.params;

+// 記事詳細データを取得
+const article: Article = await getArticleDetail(slug as string);
 ---
 <!doctype html>
 <html lang="ja">
   <meta charset="UTF-8" />
   <meta name="description" content="Astro description" />
   <meta name="viewport" content="width=device-width" />
   <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <meta name="generator" content={Astro.generator} />
     <title>Demo Blog | 詳細</title>
   </head>
   <body>
     <main>
-      詳細画面

+      <article>
+        <div>
+          <img
+            width={article.eyecatch.width}
+            height={article.eyecatch.height}
+            src={article.eyecatch.url}
+            alt={`img_${article.title}/`}
+          />
+        </div>
+
+        <h4 class="title">{article.title}</h4>
+        <p set:html={article.content} />
+      </article>
     </main>
   </body>
 </html>
```

直接記載していた画面のパスは、API から取得した値でパスを生成するように変更します。一応、HTML で標準実装されている `<article>` タグを使って、記事コンテンツであることを明示するように記載にしました。

#### API 実行を１回で終わらせる方法
ここでは **一覧取得 API** 👉 **詳細取得 API** という処理フローで記載しており、API を計２回たたいてますが、以下の様に **一覧取得 API のみ** で実装することも可能です。

パターン１：**一覧取得 API と 詳細取得 API を別で実行する場合**（上記と同じ記載）
```tsx
export async function getStaticPaths() {
	// 記事一覧 取得 API 実行
	const articles: Article[] = await getArticles();
	return articles.map((article) => ({
		params: { slug: article.id },
	}));
}
const { slug } = Astro.params;

// 記事詳細 取得 API 実行
const article: Article = await getArticleDetail(slug as string);
```

パターン２：**一覧取得 API 実行の１回のみで完了させる場合**（こんな書き方もできる）
```tsx
export async function getStaticPaths() {
	// 記事一覧 取得 API 実行
	const articles: Article[] = await getArticles();
	return articles.map((article) => ({
        params: { slug: article.id },
        props: article, // ここで props に渡す
	}));
}

// 記事詳細データとして利用
const article: Article = Astro.props;
```

実は、今回の API のサンプルデータだと「パターン２」でも実装できたのですが、分けて実行する書き方にしました。

両者の違いは、**「一覧取得時のレスポンス（配列の各要素のデータ）と、詳細取得時のレスポンスのデータ内容が一致するかどうか」** です。

一覧取得 API で取得したデータがそのまま詳細画面でも利用できるなら「パターン２」で問題ないですが、詳細取得 API でしか取得できない詳細画面用のデータが存在する場合は「パターン１」で実装しなければなりません。
つまり、CMS 側の API の定義次第です。

#### 動作確認
さて、画面が問題なく表示されるかを確認してみましょう。

**一覧画面** `http://localhost:4321/articles`

![demo_list_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/787582/fe92b9f4-3ab9-f0fb-93c7-f24f4aa27a4a.png)

**詳細画面** `http://localhost:4321/articles/b49k0v7g6`

![demo_detail_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/787582/c2e258dc-9759-8f91-c53a-f032c14a8a67.png)

ページの URL も、ちゃんと CMS 側で発行された ID を含んだ値になっています。

**詳細画面** `http://localhost:4321/articles/rytmup5w4ntx`

![demo_detail_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/787582/e022060f-8cfc-3f92-5fa9-25841fc50607.png)

`<img>` タグを含む、色んなタグを詰め込んだこちらの記事も、問題なく表示できている様です 🎉

今回はスタイルの指定が適当なのでキレイな画面ではないですが、無事に API 経由で記事データを表示するロジックが実装できました 🙌✨

## 実運用する場合の注意点

今回の実装方法は **SSG（静的ジェネレータ）** として Astro を利用をしているので、CMS 側で記事を追加しても、それだけではユーザ画面側に追加記事は **表示されません**。
これは、完全に静的なサイトとして全ページをビルドした状態でサーバにデプロイしているからです。

もし、CMS 側で記事追加など更新作業をした場合は、その度にサイトに最新の状態でビルド・デプロイを実行する手順を、自動または手動で実施する必要があることに注意してください。

CMS 側の記事更新が行われた際に、Webhook を利用して自動デプロイする様に仕組みを作ることができれば、自動化は可能です。

もしくは、Astro を SSR モードで使用し（今回は説明を割愛）、ホスティング側に SSR に対応したサービス・仕組みが存在するなら、それを利用すれば CMS を変更する度にいちいちビルド・デプロイをしなくても、更新内容が自動的に反映されます。

例）Firebase Hosting ＋ Firebase Functions、Cloudflare Pages ＋ Cloudflare Pages Function（または Cloudflare Workers）など

その代わり、**SSR を利用する場合** は、利用するサーバレスサービスが毎回動いてしまいますので、無料枠/課金枠が **運用に耐え得るプランになっているか**（大量アクセスが来ても問題ないか）ご注意ください。

これは人によって状況が違うため、ここではその具体的な方法は説明しません。ご利用している各ホスティングサービスについて調べてみてください。