microCMSを用いてキッチンカービジネスのWebサイトをつくる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/e697631f-9eb1-3da5-3823-eccab0dadf9b.png)

## microCMSを試そう

2024のQiitaアドベントカレンダー一覧をみると、microCMSテーマのカレンダーがあったので記事を書いてみることにしました。
これまで利用したことがありませんが、このような機会に少し触れてみるのも良いですね。

しかし、いざ使ってみようと考えても、目的がないとサービス・道具は意味を持ちません。
今回は架空のキッチンカービジネスのPRサイトをテーマに実装を考えてみたいと思います。[^1]

## 想定する背景

* 提携するパン屋のパンを載せて移動販売を行う
* 車内で簡単な調理を行うメニューもあり
* 情報発信のためWebサイトを持ちたい

ビジネス的な妥当性や背景はこの記事の趣旨ではないので、あまり深堀りしないものとします。

## Webサイトの機能要件

* カレンダー表示
    * 行き先や定休日などを表示
* 取り扱いメニュー
    * 期間限定メニューなど更新が簡易なこと
* お知らせ
    * 簡易に案内を表示できる仕組み
* ブログ
    * イベントへの出店など、自由に記事を追加できる

## 技術要件

* microCMSはひとまずHobby（無料版）で始めたい
* SSG（Static Site Generation）でS3などにデプロイして運用したい
    * 負荷や不具合の心配を減らす、低コストで運用するため


## ワイヤーフレーム

前述の「Webサイトの要件」を満たすようにざっと配置してみるとトップページのワイヤーフレームは下記のような感じでしょうか。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/f3525511-9297-68ac-7d98-cbd730f1cf14.png)

## コンテンツリスト

|ページ名       |パス        |
|---------------|------------|
|トップページ   |/           |
|お知らせ一覧   |/news/      |
|お知らせ詳細   |/news/[id]/ |
|メニュー一覧   |/menu/      |
|ブログ記事一覧 |/blog/      |
|ブログ記事詳細 |/blog/[id]/ |


## v0によるプロトタイプ作成

最初の第一歩はv0[^2]にお願いしてみましょう。

[^2]: v0はNext.jsを開発しているVercel Inc.が提供するReactベースのUI生成サービスです。

![スクリーンショット 2024-11-06 11.49.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/c9a3304e-9ba2-55a3-9e96-b0be0d0bdb05.png)


https://v0.dev/chat/jlicmi0yKYc?b=b_oRNOniXxjly

少し荒いですが、上記リンク先のようなサイトの土台が出来ました。
細かな余白調整などはv0と会話するより、コードを直接編集したり[Cursor](https://www.cursor.com/)や[GitHub Copilot](https://github.com/features/copilot)と対話したほうが早いので手元にコードを取り込みましょう。

手元で微調整し、ChatGPTのDALL-Eで生成した画像をはめ込んだものが下記の画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/418e9f66-4abe-6b4d-03b9-0f15ffb3405e.png)

本来はもう少しデザインなどを適用していきたいところですが、本記事はmicroCMSの検証目的のため、ワイヤーフレームっぽい状態のまま進めたいと思います。

今回はSSG想定なので、 `next.config.mjs` は下記のようにしています。

<details><summary><code>next.config.mjs</code></summary>

```js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
}

export default nextConfig;
```

</details>

これで、 `npm run build` とすると `out` ディレクトリ配下に静的なWebサイトのファイル一式が出力されます。

## microCMSの適用範囲

営業カレンダーはGoogle Calendarの埋め込みをすれば良いかなと思います。

今回は、microCMSに下記の部分を担ってもらいましょう。

* お知らせ
* メニュー
* ブログ

例えば、メニューカルーセル部分はこの時点では下記のように `menus` という定数にハードコーディングしていますが、これがAPIから取得できればよいわけですね。

<details><summary><code>MenuCarousel.tsx</code></summary>

```tsx
export function MenuCarousel() {
  const menus = [
    {
      title: "焼きたて焼きそばパン",
      price: 600,
      isNew: true,
      image: "/images/menu1.webp",
    },
    {
      title: "栗もりもりパン",
      price: 800,
      isNew: true,
      isLimited: true,
      image: "/images/menu-maron.webp",
    },
    // 省略..
  ]
  return (
    <Carousel className="mt-6">
      <CarouselContent>
        {menus.map((menu, index) => (
        <CarouselItem key={index} className="md:basis-1/2 lg:basis-1/4 pt-4">
          <Card className="rounded-none">
            <CardContent className="p-0">
              <div className="relative">
                <img
                  src={menu.image}
                  alt={menu.title}
                  className="aspect-square object-cover"
                  width={300}
                  height={300}
                />
                {menu.isLimited && (
                  <span className="absolute left-2 top-2 rounded bg-primary px-2 py-1 text-xs text-primary-foreground">
                    期間限定
                  </span>
                )}
                {menu.isNew && (
                  <span className="absolute right-0 top-0 w-10 h-10 translate-x-1/3 -translate-y-1/3 flex items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
                    NEW
                  </span>
                )}
              </div>
              <div className="p-4">
                <h3 className="font-bold">{menu.title}</h3>
                <p className="mt-1 text-lg font-bold">¥{menu.price}</p>
              </div>
            </CardContent>
          </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
} 
```

</details>

## microCMSのアカウント作成

https://microcms.io/ へアクセスして、「無料ではじめる」から登録します。
クレジットカードの登録不要というのは地味に嬉しいですね。試してみるハードルがグッと下がります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/0b88cbd8-a095-ccf8-887b-f2251f80fb78.png)

特に操作に迷うことなく、すぐにアカウント登録が出来ました。
アンケート回答後にサービス作成画面へ遷移します。

## microCMSでのサービス作成手順

「テンプレートから選ぶ」機能もあるようですが、今回は入出力したいデータが明確なので、「一から作成する」を選んでみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/5e4e0a67-78f8-8c39-49e5-4dc5533c828e.png)

後から変更可能であるため、詳細な設定は後ほど行うこととし、まずは登録を進めます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/95a96f9e-d8fb-1b84-e299-02d05702fa1b.png)

## API作成

APIを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/a7829f58-cd0e-6f6e-c6b3-2cd914288e35.png)

何やら、「ブログ」「お知らせ」と今回の要件にピッタリなものもありますね。

「お知らせ」から作ってみましょう。
ワンクリックで何やら作成できたみたいです。すごい。

何件か試しにお知らせのデータも登録してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/76d9e8ab-c1f4-5e70-4ad1-ec9c3c72798d.png)

さて、登録したデータはどのようにNext.jsに取り込むと良いでしょうか。
どうやら下記のSDKを用いるみたいです。

https://github.com/microcmsio/microcms-js-sdk

オフィシャルチュートリアルも充実しています。

https://document.microcms.io/tutorial/javascript/javascript-top

詳細は上記のサイトを確認すると間違いないと思いますが、ここでは要点だけピックアップします。

SDKのインストール

```bash
npm install microcms-js-sdk
```

`libs/client.js` の作成

```ts
import { createClient } from 'microcms-js-sdk';

export const client = createClient({
  serviceDomain: 'service-domain', 
  apiKey: 'api-key',
});
```

> `service-domain` と `api-Key` はサービスごとに異なる値のため、該当のサービスに応じた設定が必要です。
> `service-domain` には、作成したサービス「https://XXXX.microcms.io」のXXXX 部分を設定します。api-keyには、自動で作成されたAPIキーの文字列をコピーして入力します。サイドバー下部の「1個のAPIキー」を選択して、APIキー一覧に移動し、作成済みのAPIキーをコピーして貼り付けましょう。

私は前述のとおり「サービス作成」でサービスIDを `kitchen-car-pr` と入力したので、 `service-domain` を `kitchen-car-pr` に置き換えます。

`api-key` は microCMS管理画面のAPIキー管理画面からコピー用ボタンをクリックしてコピーして、`api-Key`の部分に貼り付けましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/cd392feb-37f6-c40f-ee2c-5f0b9797ca28.png)

事前準備は終わりましたので、次に実際にAPIデータを取得し、Next.jsのアプリケーション内で使用しましょう。

newsのエンドポイントからリストを取得するように
`libs/client.js` に下記を追加します。

<details><summary><code>libs/client.js</code></summary>

```ts
export type News = {
  id: string
  createdAt: string
  updatedAt: string
  publishedAt: string
  revisedAt: string
  title: string
  date: string
}

export type NewsResponse = {
  contents: News[]
  totalCount: number
  offset: number
  limit: number
}

export const getNewsList = async () => {
  const response = await cmsClient.get<NewsResponse>({
    endpoint: 'news',
    queries: { limit: 3 }
  })
  return response.contents
}
```

</details>

consoleで見てみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/ac3cd232-7da7-872a-dac0-573f1077394f.png)

うまく取得出来ていそうです。

多くのWeb APIが日時の曖昧さなくし、マシンリーダブルにするためISO 8601形式などを用いますが、microCMSも同様のようです。
そのようになっていないAPIも世の中にはありますが、困ったことになるので、しっかり標準規格で返却してくれるのはありがたいですね。

とはいっても、 `2024-11-06T12:09:38.357Z` のような記載のままだと一般ユーザにとってはわかりにくいですね。表示を変換してあげましょう。

```bash
npm install date-fns
```

<details><summary><code>NewsRecords</code></summary>

```tsx
import Link from "next/link"
import { format, parseISO } from "date-fns"
import { ja } from "date-fns/locale"

type NewsRecordProps = {
  publishedAt: string
  title: string
  href: string
}

type News = {
  id: string
  publishedAt: string
  title: string
}

function NewsRecord({ publishedAt, title, href }: NewsRecordProps) {
    const formattedDate = format(parseISO(publishedAt), 'yyyy/MM/dd', { locale: ja })
    
    return (
      <div className="flex items-start gap-4">
        <span className="text-sm text-muted-foreground">{formattedDate}</span>
        <Link className="hover:underline" href={href}>
          {title}
        </Link>
      </div>
    )
  } 

export function NewsRecords({ news }: { news: News[] }) {
  return (
    <div className="mt-6 space-y-4">
      {news.map((item) => (
        <NewsRecord key={item.id} publishedAt={item.publishedAt} title={item.title} href="#" />
      ))}
    </div>
  )
} 
```

</details>

これで、`publishedAt` が `2024-11-06T12:09:38.357Z` だった場合、
`formattedDate` は `2024/11/06` のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/9ce0d575-7283-6ba7-7b0b-0b0538a59813.png)

日付書式はうまくいきましたが、APIからの返却が `publishedAt` の降順になっていないので、 並びに違和感があります。
（デフォルトでは `createdAt` の降順のようですが、日付を任意のものを指定したいことがあるため、ここでは `publishedAt` を用います）

`getNewsList` にソート用のパラメータを追加しましょう。

<details><summary><code>client.ts</code></summary>

```ts
export const getNewsList = async () => {
  const response = await client.get<NewsResponse>({
    endpoint: 'news',
    queries: { limit: 3, orders: '-publishedAt' }
  })
  return response.contents
}
```

</details>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/d2299bf5-0c71-db11-aae1-5a2b1aead714.png)

これで想定通りの並びになりました。

## お知らせ詳細ページの実装

コンテンツリストで記載したとおり、`/news/[id]/` のようなパスで詳細ページを作成します。
まずは `client.ts` を修正してNewsのIDリストを返却する関数を追加しましょう。

<details><summary><code>client.ts</code></summary>

```ts
export const getNewsIdList = async () => {
  const ids = await client.getAllContentIds({
    endpoint: 'news'
  })
  return ids
}
```

</details>

`app/news/[id]/page.tsx` を作成して、下記のようにします。

<details><summary><code>app/news/[id]/page.tsx</code></summary>

```tsx
import { format, parseISO } from "date-fns"
import { ja } from "date-fns/locale"

type Props = {
  params: {
    id: string
  }
}

export async function generateStaticParams() {
  const { getNewsIdList } = await import('@/lib/client')
  const newsIds = await getNewsIdList()
  return newsIds.map((id) => ({
    id: id.toString(),
  }))
}

export default async function NewsDetail({ params }: Props) {
  const { getNewsDetail } = await import('@/lib/client')
  const news = await getNewsDetail(params.id)
  const formattedDate = format(parseISO(news.publishedAt), 'yyyy/MM/dd', { locale: ja })
  
  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-4">{news.title}</h1>
      <div className="text-gray-600 mb-4 text-right">{formattedDate}</div>
      { news.content && (
        <div 
          className="prose"
          dangerouslySetInnerHTML={{ __html: news.content }}
        />
      )}
    </div>
  )
} 
```

</details>

`generateStaticParams` によって生成すべきURLのリストを解決しています。

これにより、ビルド時に `/news/sywwycx7f.html` のようなIDがファイル名となるHTMLファイルが生成されるようになりました。

:::note info

`import { getNewsDetail, getNewsIdList } from "@/lib/client"` のように行頭でimportしなかったのは、SSGとしての運用を想定し、生成したファイルはmicroCMSのAPIへリクエストを飛ばさないようにするためです。
この場合は、`const { getNewsIdList } = await import('@/lib/client')` のように`function`内部で呼び出す必要がありました。

:::

## メニューのAPI実装

ブログ機能はお知らせ機能と同様にテンプレートを選択することによって進められそうなので、次にメニューのAPIを考えます。

管理画面の左メニュー「コンテンツ（API）」の「＋」マークからAPIを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/a7829f58-cd0e-6f6e-c6b3-2cd914288e35.png)

次は「自分で決める」を選択してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/7050dda7-b6de-3b24-8de0-b0ba9aac9f96.png)

リスト形式を選択。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/e9106563-965d-a5ec-f2a6-911df1e43539.png)

スキーマはこんな感じに。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/fe0e48c3-9df1-0de1-406d-f6102160e2c1.png)

作成すると「コンテンツがありません」となるので「追加」ボタンから追加してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/099fad15-04f8-9e91-621b-ff5f2b2fb952.png)

入力している中で、期間限定のフラグを入れ忘れていたことに気が付きました。
下記のように問題なくフィード追加できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/9ff7b3ef-3b13-5569-7dc3-ae6209f6b0f2.png)

「変更する」ボタンを押下すると下記のようなダイアログが出てきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/9d38e4dd-80d9-0c37-7662-80a17f31f9da.png)

本来、スキーマ変更は危険で破壊的な操作になる恐れがありますので、とても配慮頂いた実装ですね。

改めてコンテンツを入れて行きます。
入れ終わるとこんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/82e90560-2c4d-c9b4-653a-b99712c141ad.png)

APIプレビューを確認するとこんな感じ。

```json
{
    "contents": [
        {
            "id": "4-fk-b0dm",
            "createdAt": "2024-11-18T07:46:28.580Z",
            "updatedAt": "2024-11-18T07:46:28.580Z",
            "publishedAt": "2024-11-18T07:46:28.580Z",
            "revisedAt": "2024-11-18T07:46:28.580Z",
            "title": "甘口カレーパン",
            "price": 600,
            "isNew": false,
            "isLimited": false,
            "image": {
                "url": "https://images.microcms-assets.io/assets/6acf32a5bf0543a8b88d18e6fabd693c/f3cc1c834a604f678a1f95ebefff090b/menu-curry-sweet.webp",
                "height": 1024,
                "width": 1024
            }
        },
        ... 省略 ..
    ]
}
```

該当部分をAPIからデータ取得するように変更しましょう。

<details><summary><code>MenuCarousel.tsx</code></summary>

```tsx
export async function MenuCarousel() {
  const menus = await getMenuList()

  return (
    <Carousel className="mt-6">
      <CarouselContent>
        {menus.map((menu) => (
        <CarouselItem key={menu.id} className="md:basis-1/2 lg:basis-1/4 pt-4">
          <Card className="rounded-none">
            <CardContent className="p-0">
              <div className="relative">
                <img
                  src={menu.image.url}
                  alt={menu.title}
                  className="aspect-square object-cover"
                  width={300}
                  height={300}
                />
                {menu.isLimited && (
                  <span className="absolute left-2 top-2 rounded bg-primary px-2 py-1 text-xs text-primary-foreground">
                    期間限定
                  </span>
                )}
                {menu.isNew && (
                  <span className="absolute right-0 top-0 w-10 h-10 translate-x-1/3 -translate-y-1/3 flex items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
                    NEW
                  </span>
                )}
              </div>
              <div className="p-4">
                <h3 className="font-bold">{menu.title}</h3>
                <p className="mt-1 text-lg font-bold">¥{menu.price}</p>
              </div>
            </CardContent>
          </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
} 
```

</details>

<details><summary><code>client.ts</code></summary>

```ts
export const getMenuList = async () => {
  const response = await client.get<MenuResponse>({
    endpoint: 'menus',
  })
  return response.contents
}
```

</details>

これでメニュー部分においてもmicroCMSで登録したデータを参照して画面の要素が作られるようになりました。

## ビルド＆デプロイ

大まかに仕組みはできました。
あとは微調整を行い完成度を上げるのと、ビルドとホスティングサービスへのデプロイができれば完了です。

https://help.microcms.io/ja/knowledge/webhook-github-actions-settings

上記のページでは、microCMSのWebhookからGitHub Actionsに連携する方法が記載されています。
このようにすることで、下記のようなワークフローが実現できます。

* microCMSから更新（Webhook発動）
* GitHub ActionsでHTMLのビルドとS3へのアップロード

あとは、AWS S3でホスティングするだけです。

## まとめ

microCMSを用いた事例を聞く機会が非常に増えており、国内のサービスということもあり今後のさらなる普及も期待出来ます。
実際に手を動かしながら触った感覚としては公式ドキュメントが整備されていること、管理画面も分かりやすく、上手く作られている印象がありました。

ヘッドレスCMSを用いる場合は、microCMSに限らず、今どきのフロントエンド開発の知識が求められるため、少しだけ人を選ぶ技術ではありますが、Vue.jsやReactなどのSPAの何らかの経験とRest API（Web API）の利用経験があれば、今回の記事のようにすんなり導入できるのではないかと思います。

また、セキュリティ要件やそれに付随する技術要件が厳しく、公開するWebサイト一式において動的な処理をしてはいけないこともあるでしょう。
その場合は、今回のようにヘッドレスCMSの利用はあくまでビルドまでの工程として用いて、SSGしてから完全に独立した静的ファイルのみで運用することも可能となります。
こうすれば極端な話、microCMSのサービスが停止しても更新を必要としなければ、作成したWebサイトは稼働を続けられます。

ReactやNext.jsをこれから学ぼうと考えている人にっても、まずは無料で試すことができるのでこれを機会に少し遊んでみてはいかがでしょうか。

https://microcms.io/

[^1]: キッチンカーをテーマとして選んだことに深い意味はありません。ただ、ザ・ノンフィクションでキッチンカーの話をみて面白かったためです。