📖

StrapiのRich text (Block)を呼び出す

2024/06/24に公開

目的

Strapiの詳細ページで記事のコンテンツを表示する際に詰まったので、共有できたらと思い記事を書きました!
ベースのサンプルなので、スタイリングなどはしていないです。
少しでも参考になったら嬉しいです🙌🏼

Strapiとは

https://strapi.io/

  • オープンソースのHeadless CMS
  • カスタマイズ性が高いのが特徴
  • 自社サーバーでセルフホスティングできれば無料
  • SaaSのcloudサービスもある

Rich text (Block)とは

  • Rich textは、見出しタグ、テキスト、リンク、画像、リストなど様々な要素が入ってくる
  • Rich text (Markdown)もあるが、一般のCMS更新担当者はRich text(Block)の方が直感的かと思われる
  • 🚨: 外部リンクの設定はできないので、フロント側で実装する必要がある

Strapiでデータを取得する

/utils/getStrapiURL.ts

export function getStrapiURL() {
  return process.env.NEXT_PUBLIC_STRAPI_URL ?? "http://localhost:1337";
}

/data/baseUrl.ts

import { getStrapiURL } from "@/utils/getStrapiURL";
export const baseUrl: string = getStrapiURL();

/data/fetchData.ts

import { flattenAttributes } from "@/utils/flattenAttributes";

export const fetchData = async (url: string) => {
  // MEMO: Tokenを利用したデータフェッチが必要なら、この関数内で実装する
  try {
    const response = await fetch(url);
    const data = await response.json();
    return flattenAttributes(data);
  } catch (error) {
    console.error("Error fetching data:", error);
    return null;
  }
};

/data/loaders.ts

interface Condition {
  $containsi?: string;
}

interface FAQStringFilter {
  title?: Condition;
  body?: Condition;
}

interface Filters {
  $or: FAQStringFilter[];
  loginOnly?: { $ne: boolean };
}

export interface FAQ {
  id: number;
  title: string;
  loginOnly: boolean;
  faq_category: FAQCategory;
}

export interface FAQCategory {
  label: string;
  url: string;
  faqs: {
    data: FAQ[];
  };
}

export interface FAQDetail extends FAQ {
  body: BlocksContent;
}
export const getFaqById = async (id: string): Promise<FAQDetail | null> => {
  const res = await fetchData(`${baseUrl}/api/faqs/${id}`);
  if (!res) return null;
  return res;
};

取得データサンプル

/app/faq/[id]/page.tsx

import { getFaqById } from "@/data/loaders";
import { type BlocksContent } from "@strapi/blocks-react-renderer";
import { Content } from "./_components/Content";
import Link from "next/link";

type Props = {
  params: {
    id: string;
  };
};
export default async function Page({ params }: Props) {
  const { id } = params;
  const data = await getFaqById(id);
  if (!data) return null;
  const content: BlocksContent = data.body;

  return (
    <div>
      <h2>{data.title}</h2>
      <Content content={content} />
      <Link href="/faq">一覧に戻る</Link>
    </div>
  );
}

データのサンプルは👇のようになります

const data = [
  { type: "heading", children: [{ type: "text", text: "Heading1" }], level: 1 },
  { type: "heading", children: [{ type: "text", text: "Heading2" }], level: 2 },
  { type: "heading", children: [{ type: "text", text: "Heading3" }], level: 3 },
  { type: "heading", children: [{ type: "text", text: "Heading4" }], level: 4 },
  { type: "heading", children: [{ type: "text", text: "Heading5" }], level: 5 },
  { type: "heading", children: [{ type: "text", text: "Heading6" }], level: 6 },
  { type: "paragraph", children: [{ type: "text", text: "text" }] },
  {
    type: "paragraph",
    children: [{ type: "text", text: "text bold", bold: true }],
  },
  {
    type: "paragraph",
    children: [{ type: "text", text: "text underline", underline: true }],
  },
  {
    type: "paragraph",
    children: [
      { type: "text", text: "text strikethrough", strikethrough: true },
    ],
  },
  {
    type: "paragraph",
    children: [{ type: "text", text: "text italic", italic: true }],
  },
  {
    type: "paragraph",
    children: [{ type: "text", text: "text inline code", code: true }],
  },
  {
    type: "paragraph",
    children: [
      { text: "", type: "text" },
      {
        type: "link",
        url: "https://www.google.com/",
        children: [{ text: "text link", type: "text" }],
      },
      { text: "", type: "text" },
    ],
  },
  {
    type: "paragraph",
    children: [
      { text: "", type: "text" },
      {
        type: "link",
        url: "https://www.google.com/",
        children: [{ text: "text link", type: "text" }],
      },
      { text: "", type: "text" },
    ],
  },
  { type: "quote", children: [{ type: "text", text: "this is Quote sample" }] },
  {
    type: "paragraph",
    children: [{ type: "text", text: "external text link is enable setting?" }],
  },
  {
    type: "list",
    format: "unordered",
    children: [
      { type: "list-item", children: [{ type: "text", text: "list1" }] },
      { type: "list-item", children: [{ type: "text", text: "list2" }] },
      { type: "list-item", children: [{ type: "text", text: "list3" }] },
    ],
  },
  {
    type: "list",
    format: "ordered",
    children: [
      { type: "list-item", children: [{ type: "text", text: "order list1" }] },
      { type: "list-item", children: [{ type: "text", text: "order list2" }] },
      { type: "list-item", children: [{ type: "text", text: "order list3" }] },
    ],
  },
  { type: "paragraph", children: [{ type: "text", text: "this is image" }] },
  {
    type: "image",
    image: {
      name: "ergonofis-tdnYk4qOGhc-unsplash.jpg",
      alternativeText: "ergonofis-tdnYk4qOGhc-unsplash.jpg",
      url: "http://localhost:1337/uploads/ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390.jpg",
      caption: null,
      width: 3000,
      height: 4500,
      formats: {
        thumbnail: {
          name: "thumbnail_ergonofis-tdnYk4qOGhc-unsplash.jpg",
          hash: "thumbnail_ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390",
          ext: ".jpg",
          mime: "image/jpeg",
          path: null,
          width: 104,
          height: 156,
          size: 5.19,
          sizeInBytes: 5185,
          url: "/uploads/thumbnail_ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390.jpg",
        },
        large: {
          name: "large_ergonofis-tdnYk4qOGhc-unsplash.jpg",
          hash: "large_ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390",
          ext: ".jpg",
          mime: "image/jpeg",
          path: null,
          width: 667,
          height: 1000,
          size: 109.49,
          sizeInBytes: 109492,
          url: "/uploads/large_ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390.jpg",
        },
        small: {
          name: "small_ergonofis-tdnYk4qOGhc-unsplash.jpg",
          hash: "small_ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390",
          ext: ".jpg",
          mime: "image/jpeg",
          path: null,
          width: 334,
          height: 500,
          size: 34.74,
          sizeInBytes: 34736,
          url: "/uploads/small_ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390.jpg",
        },
        medium: {
          name: "medium_ergonofis-tdnYk4qOGhc-unsplash.jpg",
          hash: "medium_ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390",
          ext: ".jpg",
          mime: "image/jpeg",
          path: null,
          width: 500,
          height: 750,
          size: 68.26,
          sizeInBytes: 68261,
          url: "/uploads/medium_ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390.jpg",
        },
      },
      hash: "ergonofis_tdn_Yk4q_O_Ghc_unsplash_2ceeeb3390",
      ext: ".jpg",
      mime: "image/jpeg",
      size: 1824.56,
      previewUrl: null,
      provider: "local",
      provider_metadata: null,
      createdAt: "2024-06-24T00:02:12.730Z",
      updatedAt: "2024-06-24T00:02:12.730Z",
    },
    children: [{ type: "text", text: "" }],
  },
];

データ型

BlocksContent に格納されている

import { type BlocksContent } from "@strapi/blocks-react-renderer";

利用方法

blocks-react-renderer から BlocksRenderer をインポートしてカスタマイズする

https://github.com/strapi/blocks-react-renderer

カスタマイズのサンプル

ポイント

  • BlocksRenderer はCSRなので 'use client'をつける必要がある
  • <ul></ul><ol></ol>の取り回しに癖がある
  • linkについて、外部リンクかどうかはStrapi側に関心がない。つまり、外部リンクにするかどうかは実装側で決定する必要がある
  • 外部リンクを実装するなら、自身のドメインと比較=>違う場合はtarget="_blank"を指定。という手法が取られているようです

/app/faq/[id]/_components/Content.tsx

"use client";

import {
  type BlocksContent,
  BlocksRenderer,
} from "@strapi/blocks-react-renderer";
import Image from "next/image";
import Link from "next/link";

export const Content = ({ content }: { content: BlocksContent }) => {
  return (
    <BlocksRenderer
      content={content}
      blocks={{
        paragraph: ({ children }) => (
          <p className="" style={{}}>
            {children}
          </p>
        ),
        heading: ({ children, level }) => {
          switch (level) {
            case 1:
              return <h1>{children}</h1>;
            case 2:
              return <h2>{children}</h2>;
            case 3:
              return <h3>{children}</h3>;
            case 4:
              return <h4>{children}</h4>;
            case 5:
              return <h5>{children}</h5>;
            case 6:
              return <h6>{children}</h6>;
            default:
              return <h1>{children}</h1>;
          }
        },
        link: ({ children, url }) => <Link href={url}>{children}</Link>,
        image: ({ image }) => {
          return (
            <Image
              src={image.url}
              width={image.width}
              height={image.height}
              alt={image.alternativeText || ""}
            />
          );
        },
        list: (props) => {
          if (props.format === "ordered") {
            return <ol>{props.children}</ol>;
          }

          return <ul>{props.children}</ul>;
        },
        "list-item": (props) => <li>{props.children}</li>,
      }}
    />
  );
};

個人的感想

🙆‍♂️Good

  • @strapi/blocks-react-renderer で型を共有してくれているのが助かる
  • 受け取ったデータをカスタマイズするコンポーネントが提供されているのがいい
  • カスタマイズする BlocksRenderer コンポーネントはMUIライクにカスタマイズできるので、人によっては認知負荷が低い

🙅‍♂️Bad

  • 外部リンクの指定がStrapi側から実装できないのが痛い。要件によってはCMSの選定から外れると思った
    (自分が知らないだけで、外部リンクの設定ができる?)

以上となります!
まだStrapiの初心者ですので、ご意見やアドバイスなどあったらコメントにて教えていただけると
とても嬉しいです!❤️

Discussion