microCMSとNext.jsで作る「メッセージinボトル」

デザイナーでもバックエンドを気にせずデータの送受信ができるサービスを、手軽に作ってみたい！
というわけで、microCMSとNext.jsを使ってそれっぽいプロジェクトに挑戦しました。
今回はその制作プロセスをざっくり共有します！

## どんなものを作る？
突然ですが、こんなシチュエーション、ありませんか？
「気の利いたメッセージや、日々の叫びやつぶやきをボトルに詰めて海に流したい！」
ありますよね。…あると、思います。

というわけで、以下のようなものを作ってみました。

メッセージを投稿すると、すでに投稿された中からランダムに1件が返ってくる。
誰かのメッセージを受け取れる体験を提供するインタラクティブなWebサービス。

## 使用した技術
* フロントエンド … Next.js（React + TypeScript）
* バックエンド … microCMS（ヘッドレスCMS）
* プラットフォーム … Vercel、GitHub
* サポートAI … ChatGPT、Copilot（AIコーディング支援）

## 開発プロセス

### なぜヘッドレスCMS？
バックエンドやデータベース構築の知識があまりない私でも扱いやすく、
microCMSは「データの管理」と「API提供」を簡単に実現できる点が魅力でした。

### サーバーレスな設計
サーバーの管理や構築を一切せず、データベース的な役割をmicroCMSに、
デプロイはVercelにお任せ。サーバーレスアーキテクチャの恩恵を活用。

## APIの設計
今回作成したPOST APIの概要です。

### スキーマ
| フィールド|表示名| 種類 | 必須 | 詳細設定 |
|:-----------:|:----------:|:------------:|:------------:|:------------:|
| message |メッセージ| テキストエリア | true         | 文字数を制限   |

ヘッドレスCMSにデータを送るには、microCMSの「APIキー管理」でPOSTリクエストを有効化します。
※ Hobby(無料)版だとPOST使えないので有料版のみ（今回はお試しなのでトライアルで）

![スクリーンショット 2024-12-15 16.52.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62365/b9530e57-1225-5e87-a214-577931196c93.png)


## フロントエンドの実装
### 環境セットアップ
Next.jsのプロジェクトを作成
…気づけば…15…！
```
npx create-next-app@latest
```

* 必要なコンポーネントやページを配置（ChatGPTに色々お願いしつつ）
* App RouterのRoute HandlersでmicroCMSにメッセージを投稿


#### メッセージ入力ページ

```react:page.tsx
  async function handleSubmit(event: React.FormEvent) {
    event.preventDefault();
    setIsSubmitting(true);
    setError('');
    try {
      // 入力されたメッセージを取得
      const message = textareaRef.current?.value || '';
      // API Routeにデータを送信
      const response = await fetch('/api/sendMessage', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message }),
      });
      // レスポンスの確認
      const result = await response.json();
      if (!response.ok) {
        throw new Error(result.error || '送信に失敗しました');
      }
      // 成功時に別のページにリダイレクト
      router.push('/random');
    } catch {
      setError('エラーが発生しました');
    } finally {
      setIsSubmitting(false);
    }
  }
  return (
    <div className={`${css.contents_wrap}`}>
      <p>メッセージをボトルにいれて海に流してみましょう</p>
      <form onSubmit={handleSubmit}>
        <div className={`${css.form_wrap}`}>
          <div className={`${css.form_inner}`}>
            <label htmlFor="message" className={`${css.label}`}>
              Dear...
            </label>
            <textarea
              ref={textareaRef}
              className={`${css.textarea}`}
              name="message"
              placeholder="Write your message here"
              required
              defaultValue=""
              onInput={handleInput}
            ></textarea>
            {error && <p className={`${css.error}`}>{error}</p>}
            <button type="submit" disabled={isSubmitting} className={`${css.button}`}>
              {isSubmitting ? '送信中...' : 'メッセージを送る'}
            </button>
          </div>
        </div>
      </form>
    </div>
  );
```

#### Route
```react:route.tsx
export async function POST(request: Request) {
  try {
    const { message } = await request.json();

    if (!message) {
      return NextResponse.json({ error: 'メッセージは必須です' }, { status: 400 });
    }
    const microCMSResponse = await fetch('YOUR_API_ENDPOINT', {
      headers: {
        'X-MICROCMS-API-KEY': process.env.MICROCMS_API_KEY || '',
        'Content-Type': 'application/json',
      },
      method: 'POST',
      body: JSON.stringify({ message }),
    });
    if (!microCMSResponse.ok) {
      const error = await microCMSResponse.json();
      return NextResponse.json(
        { error: error.message || '送信に失敗しました' },
        { status: microCMSResponse.status }
      );
    }
    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json({ error: 'サーバーエラーが発生しました' }, { status: 500 });
  }
}
```


#### ランダムメッセージページ
送信後はすでに投稿されたメッセージからランダムに1件メッセージが表示されるページに遷移します。

```react:page.tsx
export default async function Home() {
  // 記事を取得
  const contents = await getRandomPosts();
  // ランダムなインデックスを生成
  const getRandomIndices = (num: number, max: number): number[] => {
    const indices: number[] = [];
    while (indices.length < num) {
      const index = Math.floor(Math.random() * max);
      if (!indices.includes(index)) {
        indices.push(index);
      }
    }
    return indices;
  };
  //現在は1件だけど、複数設定できる
  const randomIndices = getRandomIndices(1, contents.length);
  const randomContents = randomIndices.map((index) => contents[index]);

  return (
    <>
      <ul className={`${css.list}`}>
        {randomContents.map((post) => (
          <li key={post.id}>
            <p>誰かのメッセージが流れ着きました</p>
            <article className={`${css.message_wrap}`}>
              <h2 className={`${css.head}`}>Dear...</h2>
              <div className={`${css.message}`}>{post.message || 'メッセージはありません'}</div>
            </article>
          </li>
        ))}
      </ul>
    </>
  );
}
```


## デプロイ
 * GitHubとVercelの連携
コードをGitHubにプッシュするたびにVercelが自動ビルドを実行。

## 完成
* メッセージを投稿すると、ランダムで誰かのメッセージが返ってくる
    （デモページではRandomからアクセスして確認）
* Collectionにこれまで投稿したメッセージ一覧を表示

![image.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62365/fb253e69-69c3-f508-4fc0-00e44fc1b7f3.jpeg)

[デモはこちらです](https://message-in-a-bottle-blush.vercel.app/)
※送信機能については、Hobby(無料)版のため使えなくなっています。

## 振り返り
### 良かった点
* バックエンドに触れずとも、インタラクティブなサービスを作れた！
* ヘッドレスCMSやサーバーレスの使い勝手の良さを実感
* microCMSとNext.js(App Route）を駆使して、フロントエンドとバックエンドをシンプルに統合

### 反省点
* フォーム送信時や、メッセージ表示に演出を加えたかった
* クライアントサイドとサーバーサイドの理解がまだイマイチ
* ISR設定だけだと、自分の投稿したメッセージがコレクションに反映されるまで時間がかかる→On-demand ISRを使えば解決する？

 
正直デザイナーでも簡単に…とまではいきませんでしたが、それっぽいサービスを作ることはできそうでした！
ローカルの環境とかも作りやすくて、昔に比べて格段にできることが増えるので、制作の幅が広がりそうです。