microCMS

繰り返しフィールド・カスタムフィールドをマスターしよう

りゅーそう

この記事は公開後、1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。野崎です。

microCMSのカスタムフィールド・繰り返しフィールドをご存知ですか?
カスタムフィールド・繰り返しフィールドを活用すると柔軟にコンテンツを入稿することができるようになります。

この記事では、カスタムフィールド・繰り返しフィールドを活用し、柔軟かつ規則性のあるコンテンツを作成する方法を紹介します。
題材としてそれぞれのトピックごとにブロックで分かれつつも連続した記事を投稿する仕組みを作成してみます。
イメージとしては以下のようなページです。



いわゆるブロックエディタのようなものをイメージしていただければと思います。
上のページはざっくりと以下のようなブロックに分けて実装されています。



このようなブロックの作成は、カスタムフィールドと繰り返しフィールドを活用することで可能です。
今回はさらにカスタムフィールドの中でカスタムフィールドを繰り返します。



やや複雑そうに見えますが、microCMSではわかりやすいUIでこれらのフィールドを定義することができます。
今回は上記のような、トピックごとにメモを追加していくようなページをNext.js(TypeScript)で実装する例を紹介します。
カスタムフィールドと繰り返しフィールドをマスターしたい方はぜひ最後まで読んでみてください!

はじめてのカスタムフィールド

サイトの画面を作成する前にmicroCMSの管理画面からカスタムフィールドを作成してみましょう。
基本的な管理画面の操作方法などはNext.jsチュートリアルドキュメントなどをご覧ください。

それでは最初のカスタムフィールドを管理画面で作成してみます。
サイトの画面でいうところの最小のパーツを作成していきます。


まずはコンテンツAPIにカスタムフィールドを追加していきます。
①コンテンツ一覧画面の右上のカスタムフィールドをクリックしてカスタムフィールド作成画面に遷移します。

②カスタムフールどの基本情報を入力します。ここではシンプルにリッチエディタのみを追加するので、

  • フィールド名:リッチエディタ
  • フィールドID:richEditor

を設定します。
③右下の次へボタンをクリックして次の設定へと進みます。

次にAPIスキーマを定義します。リッチエディタを選択します。

最後にレイアウトを設定します。今回のフィールドは一つだけですが、複数ある場合は2カラムのレイアウトを組んで管理画面で編集しやすくすることも可能です。


以上で最初のカスタムフィールドが作成されました!
カスタムフィールドの一覧に以下のようなフィールドが表示されているかと思います。


同様にもう2つほどカスタムフィールドを追加してみましょう。

  • フィールドID:markdown
  • フィールド名:マークダウン
  • フィールドの種類:テキストエリア

  • フィールドID:richlink
  • フィールド名:リッチリンク
  • フィールドの種類:テキストフィールド



このあたりは作成したいサイトの構造をイメージしながら、好きなフィールドを追加してみてください。
今回はシンプルにカスタムフィールド1つにつき1つのフィールドを追加しましたが、複数追加することも可能です。
詳しく知りたい方は以下の記事なども参考にしてみてください

カスタムフィールドを使用してブログにCVエリアを追加しよう
microCMSでAmazonアソシエイトをはじめよう


はじめての繰り返しフィールド

それでは次に先ほど作成したカスタムフィールドを繰り返すブロックを作成してみましょう。
ここで実装するのは以下の部分になります。



今回は先ほどのカスタムフィールドを利用しつつ、「Tech」に関するメモを残すブロックと「Hobby」に関するメモを残すブロックに分けて作成してみます。
まずは「Tech」用のカスタムフィールドを作成します。
先ほどと同様にカスタムフィールドを作成していきます。ここでは

  • フィールド名:Tech
  • フィールドID:tech

で作成します。

次にスキーマを定義します。以下のようなスキーマを作成しました。
ポイントは繰り返しフィールドを用いて先ほどのカスタムフィールドを繰り返すことです。


先ほど作成したカスタムフィールドを選択して、繰り返しフィールドに登録してみましょう。


「Hobby」用のカスタムフィールドも同様に作成します。今回はTechと同じように作成しました
前項と合わせて作成されたカスタムフィールドは以下の通りです。


コンテンツAPIにカスタムフィールドを設定する


管理画面の最後にコンテンツAPIのスキーマに今回作成したカスタムフィールドを登録します。
設定方法はこれまでとほぼ同様です。
ここで実装するのは以下の部分になります。


画面右上の「API設定」をクリックして、APIスキーマを以下のように設定します。

ポイントは繰り返しフィールドを設定することです。先ほど設定したカスタムフィールドを繰り返しフィールドに設定しましょう。
これでネストする繰り返しフィールドを表現することができます!


コンテンツを入稿する

では作成したコンテンツAPIに実際にコンテンツを入稿してみましょう。
コンテンツをブロックごとに追加していく感覚でコンテンツ編集を行うことができます。


コンテンツを作成したらAPIプレビューを試してみましょう。コンテンツ編集画面右上の「APIプレビュー」のメニューより試すことができます。

レスポンスは以下のようになります。繰り返しフィールドは fieldId で判別することができることがわかります。

{
    "id": "v0thmr6x-gec",
    "createdAt": "2022-01-22T13:54:18.027Z",
    "updatedAt": "2022-01-26T09:27:44.052Z",
    "publishedAt": "2022-01-22T13:54:18.027Z",
    "revisedAt": "2022-01-26T09:27:44.052Z",
    "title": "テスト",
    "topic": [
        {
            "fieldId": "tech",
            "title": "トピック1",
            "body": [
                {
                    "fieldId": "markdown",
                    "markdownText": "```js\nconst greeting = \"hello\"\n\nconsole.log(greeting)\n```"
                },
                {
                    "fieldId": "richeditor",
                    "richText": "<p>リッチエディタ入稿もできます<br></p>" ##省略
                },
                {
                    "fieldId": "richlink",
                    "url": "https://www.ryusou.dev/blogs/my-favorite-microcms"
                }
            ]
        },
        {
            "fieldId": "hobby",
            "title": "何気ないこと",
            "body": [
                {
                    "fieldId": "richeditor",
                    "richText": "<p>こんな感じで技術以外の何気ないことをつぶやいてみる ##省略
                }
            ]
        },
        {
            "fieldId": "tech",
            "title": "トピック3",
            "body": [
                {
                    "fieldId": "markdown",
                    "markdownText": "```js\nconst greeting = \"hello\"\n\nconsole.log(greeting)\n```"
                }
            ]
        }
    ]
}


以上で管理画面でのAPIの準備は完了です。

Next.jsで繰り返しフィールドを実装する

Next.jsで繰り返しフィールドを実装する方法を紹介します。
サンプルとなるリポジトリはこちらです。
今回は詳細ページの実装を紹介します。
https://github.com/YouheiNozaki/new-ryusou-portforio/

今回作成するページの実装の概要は以下のようになります。

import { Fragment } from 'react';
import { GetServerSideProps, GetServerSidePropsContext } from 'next';
import { useRouter } from 'next/dist/client/router';
import dayjs from 'dayjs';
import clsx from 'clsx';
import { BiCalendarAlt } from 'react-icons/bi';
import { FcDocument, FcMusic } from 'react-icons/fc';
import { Layout } from 'components/common/Layout';
import type { Scrap } from 'types/scraps';
import { HeadTemplate } from '../../components/common/Head';
import { Heading2 } from '../../components/ui/Heading2';
import { MarkdownField } from '../../components/ui/MarkdownField';
import { clientScraps } from '../../lib/fetchScraps';
import styles from '../../styles/scrap.module.scss';


type Props = {
  scrap: Scrap;
};


export const getServerSideProps: GetServerSideProps = async (
  context: GetServerSidePropsContext<{ id: string }>,
) => {
  const { id } = context.params;
  const data = await clientScraps.get<Scrap>({
    endpoint: `scraps/${id}`,
  });


  return {
    props: { scrap: data },
  };
};


const ScrapDetail: React.FC<Props> = ({ scrap }) => {
  const router = useRouter();


  if (router.isFallback) {
    return <div>Loading</div>;
  }


  return (
    <Layout>
      <HeadTemplate
        pagetitle={scrap.title}
        pagedescription={scrap.title}
        pagepath="scraps"
      />
      <div className={styles.scrapDescription}>
        <Heading2 title={scrap.title} />
        <div className={styles.scrapDays}>
          <BiCalendarAlt className={styles.scrapDayIcon} />
          <p className={styles.scrapDay}>
            {dayjs(scrap.createdAt).format('YYYY/MM/DD')}
          </p>
        </div>
      </div>
      <div>
        {scrap?.topic?.map((topic, id) => (
          <Fragment key={id}>
            {topic.fieldId === 'tech' && (
              <div className={clsx(styles.scrapWrapper, styles.techWrapper)}>
                <span className={clsx(styles.label, styles.techLabel)}>
                  <FcDocument size={16} />
                  <p className={clsx(styles.text, styles.techText)}>tech</p>
                </span>
                <div className={styles.detail}>
                  <h3>{topic.title}</h3>
                  <div>
                    {topic.body.map((body, index) => {
                      return body.fieldId === 'richeditor' ? (
                        <div key={index} className={styles.letterBody}>
                          <div
                            dangerouslySetInnerHTML={{ __html: body.richText }}
                          />
                        </div>
                      ) : body.fieldId === 'markdown' ? (
                        <div key={index} className={styles.letterBody}>
                          <MarkdownField text={body.markdownText} />
                        </div>
                      ) : body.fieldId === 'richlink' ? (
                        <Fragment key={index}>
                          <a href={body.url}>{body.title}</a>
                        </Fragment>
                      ) : null;
                    })}
                  </div>
                </div>
              </div>
            )}
            {topic.fieldId === 'hobby' && (
              <div className={clsx(styles.scrapWrapper, styles.hobbyWrapper)}>
                <span className={clsx(styles.label, styles.hobbyLabel)}>
                  <FcMusic size={20} />
                  <p className={clsx(styles.text, styles.hobbyText)}>Hobby</p>
                </span>
                <div className={styles.detail}>
                  <h3>{topic.title}</h3>
                  {topic.body.map((body, index) => {
                    return body.fieldId === 'richeditor' ? (
                      <div key={index} className={styles.letterBody}>
                        <div
                          dangerouslySetInnerHTML={{ __html: body.richText }}
                        />
                      </div>
                    ) : body.fieldId === 'markdown' ? (
                      <div key={index} className={styles.letterBody}>
                        <MarkdownField text={body.markdownText} />
                      </div>
                    ) : body.fieldId === 'richlink' ? (
                      <Fragment key={index}>
                        <a href={body.url}>{body.title}</a>
                      </Fragment>
                    ) : null;
                  })}
                </div>
              </div>
            )}
          </Fragment>
        ))}
      </div>
    </Layout>
  );
};


export default ScrapDetail;


順番に解説します。
まずはTypeScriptの型定義です。別ファイルに以下のような型を用意しました。WEBサイト制作ではTypeScriptは必須ではありませんが、用意しておくと実装の指針となるので、繰り返しフィールドのパターンが増える時など準備しておくと便利です。

type Body = {
  fieldId: 'richlink' | 'markdown' | 'richeditor';
  // richeditor
  richText: string;
  // richlink
  title: string;
  url: string;
  image: {
    url: string;
    height: string;
    width: string;
  };
  // markdown
  markdownText: string;
};


type Topic = {
  fieldId: 'tech' | 'hobby';
  title: string;
  body: Body[];
};


export type Scrap = {
  id: string;
  title: string;
  topic: Topic[];
  createdAt: string;
};


export type Scraps = {
  contents: Scrap[];
};


次にNext.jsでのデータ取得です。
Next.jsでは getStaticProps を使用するとSSG、getServerSideProps を使用するとSSRの挙動になります。
今回は

  • コンテンツをビルド待ちなく反映させたい
  • コンテンツ数が増える可能性がある

などの理由からSSRで実装をしてみました。

export const getServerSideProps: GetServerSideProps = async (
  context: GetServerSidePropsContext<{ id: string }>,
) => {
  const { id } = context.params;
  const data = await clientScraps.get<Scrap>({
    endpoint: `scraps/${id}`,
  });


  return {
    props: { scrap: data },
  };
};


最後に繰り返しフィールドの分岐処理です。
fieldId をもとに処理を分けます。

 {scrap?.topic?.map((topic, id) => (
          <Fragment key={id}>
            {topic.fieldId === 'tech' && (
              <div className={clsx(styles.scrapWrapper, styles.techWrapper)}>
                <span className={clsx(styles.label, styles.techLabel)}>
                  <FcDocument size={16} />
                  <p className={clsx(styles.text, styles.techText)}>tech</p>
                </span>
                <div className={styles.detail}>
                  <h3>{topic.title}</h3>
                  <div>
                    {topic.body.map((body, index) => {
                      return body.fieldId === 'richeditor' ? (
                        <div key={index} className={styles.letterBody}>
                          <div
                            dangerouslySetInnerHTML={{ __html: body.richText }}
                          />
                        </div>
                      ) : body.fieldId === 'markdown' ? (
                        <div key={index} className={styles.letterBody}>
                          <MarkdownField text={body.markdownText} />
                        </div>
                      ) : body.fieldId === 'richlink' ? (
                        <Fragment key={index}>
                          <a href={body.url}>{body.title}</a>
                        </Fragment>
                      ) : null;
                    })}
                  </div>
                </div>
              </div>
            )}
            {topic.fieldId === 'hobby' && (
              <div className={clsx(styles.scrapWrapper, styles.hobbyWrapper)}>
                <span className={clsx(styles.label, styles.hobbyLabel)}>
                  <FcMusic size={20} />
                  <p className={clsx(styles.text, styles.hobbyText)}>Hobby</p>
                </span>
                <div className={styles.detail}>
                  <h3>{topic.title}</h3>
                  {topic.body.map((body, index) => {
                    return body.fieldId === 'richeditor' ? (
                      <div key={index} className={styles.letterBody}>
                        <div
                          dangerouslySetInnerHTML={{ __html: body.richText }}
                        />
                      </div>
                    ) : body.fieldId === 'markdown' ? (
                      <div key={index} className={styles.letterBody}>
                        <MarkdownField text={body.markdownText} />
                      </div>
                    ) : body.fieldId === 'richlink' ? (
                      <Fragment key={index}>
                        <a href={body.url}>{body.title}</a>
                      </Fragment>
                    ) : null;
                  })}
                </div>
              </div>
            )}
          </Fragment>
        ))}


大きなブロックとして topic.fieldId === "tech"topic.fieldId === "hobby" で処理を分けています。fieldIdが"tech"だったら、青い見た目にして、fieldIdが"hobby"だったら赤い見た目にしてのような実装のイメージです。
ブロックごとにCSSで見た目を変えたり、ブロックの持つフィールドの形式を変えたりすることで様々なデータ構造・レイアウトを実装することができるかと思います。
以下のようにフィールドごとに、コンポーネント化すると処理がみやすくなって良いかと思います。

 <div>
    <MarkdownField text={body.markdownText} />
 </div>


最後に

microCMSの繰り返しフィールド・カスタムフィールドを活用することで、入稿しやすい形式にしたり、様々なレイアウトを組むことが可能になります。今回は簡単に実装を紹介させていただきましたが、詳しい実装方法については以下のリポジトリをご覧ください。
https://github.com/YouheiNozaki/new-ryusou-portforio/

コンポーネントの作り方やHTMLをパースする方法などは、別記事で解説できればと思っています。
ご期待ください!

まずは、無料で試してみましょう。

APIベースの日本製ヘッドレスCMS「microCMS」を使えば、 ものの数分でAPIの作成ができます。

microCMSを無料で始める

microCMSについてお問い合わせ

初期費用無料・14日間の無料トライアル付き。ご不明な点はお気軽にお問い合わせください。

お問い合わせ

microCMS公式アカウント

microCMSは各公式アカウントで最新情報をお届けしています。
フォローよろしくお願いします。

  • X
  • Discord
  • github

ABOUT ME

りゅーそう
1994年生まれ。 前職は高校地理歴史科教員。2021/9〜microCMS入社。React/TypeScriptが好きです。