ホームIT Admin Blog

Contentfulで作るコーポレートサイト

近年、多くの個人・企業で採用されることも多くなってきた技術である、ヘッドレスCMSを使ってコーポレートサイト及び本ブログをリニューアルしました。今回はヘッドレスCMSの中でも採用した「Contentful」と実装の裏側の話をしていきます。

ヘッドレスCMSとは

ヘッドレスCMSとは、Webページの表示部分(ヘッド)を持たず(レス)、コンテンツ管理のみを行うCMS (Contents Management System)のことを指します。
ブログなどでよく用いられるWordPressなどでは、メインページ・固定ページなどの表示部分とコンテンツの管理機能がセットで準備されますが、ヘッドレスCMSでは表示部分が用意されず、以下の3つの機能のみの提供となるので表示部分を自分で制作する必要があります。
  • 管理画面
  • データベース
  • 記事を取得するAPI ( 希望のコンテンツや記事をJSON形式で取得可能 )
この場合、表示部分を自分で一から作ることができるので、利用できる言語やフレームワークの幅が広がったり、「このお知らせの部分にCMSを組み込もう」などといった柔軟なページ作成を行うことができます。また、別のプラットフォームで同じものを埋め込みたい場合であってもAPIへアクセスするだけで同様のことができ、コンテンツの再利用性が高まります。
実際に弊社のコーポレートサイトでは、一例ですが SaaSの販売と運用 の「取扱製品」の部分で使用しています。コードを編集する必要がないため、どなたでも取扱製品のフィールドに対して編集を加えることが可能です。

Contentfulとは

Contentfulはドイツ発(2013年創業)の有名なヘッドレスCMSの一種です。柔軟なコンテンツモデル設計が可能、配信画像のリサイズAPIが使えたりと、必要十分な機能を備えています。
Content that scales. Experiences that convert.
Contentful DXP uses AI-driven analytics to help you personalize, optimize, and c...
favicon
www.contentful.com
Markdownはもちろんのこと、Rich Textという画像やオブジェクトの埋め込みが容易にできるエディタが用意されており、直感的に操作ができます。
よく比較される MicroCMS との違いは、無料プランでも本番と開発環境で分離してコンテンツの管理が可能な点であると思います。開発環境下であれば、急なオブジェクトの追加やレスポンススキーマの変更であっても安心して行うことができます。
更に、下書き中の記事を実際のページで表示させて確認できる Content Preview機能もあり、ビジュアルに内容の修正が可能です。(画像は特徴の Live Preview 及び Inspector Mode を有効にしてあります)

構成

技術スタックは以下の通りです。表示の都合で外部向けと内部向けでビルドの方法を分けています。
  • Next.js (v13.4.19) × TypeScript
    • App Routerへ全面移行予定ですが、 現状Pages Routerも混在しています
  • Contentful
    • 本ブログ、コーポレートサイトの部分部分で使用
  • CloudFlare Pages (SSG・外部向け)
  • GCP: Cloud Run (SSR・内部向け(プレビュー機能))

SSGとSSRの出し分け

先程ご紹介したプレビュー機能を実装するに当たって、環境に応じてSSG/SSRを分けてビルドする必要がありました。
そのままではSSRとして動かしたい環境でもSSGとしてビルドされてしまうので、プレビュー環境かどうかを判別する変数を用意し、module.exports内で設定を分けることで解決しました。(ソースコードは一部抜粋)
module.exports = () => {
  // Preview Modeが無効の場合はexportする
  if (process.env.NEXT_PUBLIC_PREVIEW_MODE === "false" || process.env.NEXT_PUBLIC_PREVIEW_MODE === undefined) {
    return {
      output: "export",
    };
  }

  // Preview Modeが有効の場合はstandaloneで出力する
  return {
    output: "standalone",
  };
};
上の問題に付随して、かなり特殊ではあると思いますが、App Routerで動かしているページ(SSRでレンダリングしたい)でSSG回避するにはnotfound()で404ページを出力することで回避できます。
  // SSGの場合は404ページを表示する
  if (
    process.env.NEXT_PUBLIC_PREVIEW_MODE === undefined ||
    process.env.NEXT_PUBLIC_PREVIEW_MODE === "false"
  ) {
    return notFound();
  }

Live Preview使用時に埋め込んでいるアセットを読み込めなくなる

Content Preview機能のLivePreviewを使っている場合で、画像やオブジェクトを差し替えると、今まで読み込んでいた要素が急にundefinedになってしまう問題がありました。
そこで記事の取得時に、記事内に存在するオブジェクトを保持しておき、undefinedになってしまったオブジェクトはそこから参照するようにすることで解決しました。 (ソースコードは一部抜粋)
 // bodyの中にある画像のURLを取得する
  const images = post?.fields.body?.content
    .filter((content) => content.nodeType === "embedded-asset-block")
    .map((content) => {
      return {
        imageId: content.data.target.sys.id,
        imageUrl: content.data.target.fields.file.url,
      };
    });
const imageUrl = images?.find((image) => image.imageId === node.data.target.sys.id)?.imageUrl;
// プレビュー環境下(imageUrlが存在する) の場合
if (isPreview && imageUrl && node.data?.target) {
  const { description, file } = node.data.target.fields;
  return (
    <div className={styles.embeddedImage}>
      <Image
        src={file?.url ? file.url : "https://" + imageUrl}
        alt={description}
        quality={60}
        priority={true}
        width={800}
        height={600}
        style={{
          maxWidth: "100%",
          height: "auto",
        }}
      />
    </div>
  );
}

ブログカードの実装

以下の例のような、QiitaやZennなどのエンジニア向け情報共有コミュニティサイトで用いられているブログカードを生成する際に、どうメタタグを取得しようか悩んでいましたが、Littleforestさんの執筆された「外部サイトのOGPを取得する」が非常に参考になりました!
特定の外部サービスの場合で、oEmbedの形式で埋め込み情報が提供されている場合は、各サービスの提供しているoEmbedAPIエンドポイントより投稿情報を取得しています。
Content that scales. Experiences that convert.
Contentful DXP uses AI-driven analytics to help you personalize, optimize, and c...
favicon
www.contentful.com
外部サイトのOGPを取得する
favicon
zenn.dev
export async function getMetaTags(
  urlData: string[] | undefined
): Promise<MetaTag[] | undefined> {
  if (urlData == undefined) {
    return;
  }

  return await Promise.all(
    urlData.map(async (url) => {
      const urlHostname = new URL(url).hostname;

      // ホスト名で分岐
      switch (urlHostname) {
        case "www.youtube.com": {
          const videoId = new URL(url).searchParams.get("v");

          // videoIdが規定の形式になっているかチェック
          if (!videoId?.match(/[a-zA-Z0-9_-]*/)) {
            return {
              url: url,
              error: true,
            };
          }

          return {
            url: url,
            serviceName: "YouTube",
            embed: videoId,
          };
        }
        case "x.com":
        case "twitter.com": {
          const postId = new URL(url).pathname.split("/")[3];

          // 投稿IDが規定の形式になっているかチェック
          if (!postId) {
            return {
              url: url,
              error: true,
            };
          }

          return {
            url: url,
            serviceName: "X",
            embed: postId,
          };
        }
        case "speakerdeck.com": {
          const res = await fetch(
            `https://speakerdeck.com/oembed.json?url=${url}`
          ).then((res) => res.json());

          if (res.error) {
            return {
              url: url,
              error: true,
            };
          }

          return {
            url: url,
            serviceName: "SpeakerDeck",
            embed: String(res.html),
          };
        }
        case "vimeo.com": {
          const res = await fetch(
            `https://vimeo.com/api/oembed.json?url=${url}`
          ).then((res) => res.json());

          if (res.error) {
            return {
              url: url,
              error: true,
            };
          }

          return {
            url: url,
            serviceName: "Vimeo",
            embed: String(res.html),
          };
        }
        case "www.linkedin.com": {
          // パス名から投稿IDを抽出
          const pathName = new URL(url).pathname.split("/")[3];

          // 投稿IDが規定の形式になっているかチェック
          if (!pathName.match(/^urn:li:(ugcPost|share):\d+$/u)) {
            return {
              url: url,
              error: true,
            };
          }

          const baseUrl = `https://www.linkedin.com/embed/feed/update/${pathName}`;
          return {
            url: url,
            serviceName: "LinkedIn",
            embed: `<div style="position:absolute; top:0px; left:0px; bottom:0px; right:0px; width:100%; height:100%; border:none; margin:0; padding:0; overflow:hidden;"><iframe style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" src="${baseUrl}" loading="lazy" frameborder="0" title="LinkedIn" sandbox="allow-scripts"></iframe></div>`,
          };
        }
        default:
          try {
            const res = await fetch(url).then((res) => res.text());
            const dom = new JSDOM(res);

            // metaタグから,title, description, og:imageを取得
            const title =
              dom.window.document.querySelector("title")?.textContent;
            const description = dom.window.document
              .querySelector("meta[name='description']")
              ?.getAttribute("content");
            const ogImage = dom.window.document
              .querySelector("meta[property='og:image']")
              ?.getAttribute("content");

            return {
              url: url,
              title: title ? title : "",
              description: description ? description : "",
              ogImage: ogImage ? ogImage : "",
            };
          } catch (e: unknown) {
            if (e instanceof Error) {
              console.error(e.message);
            }
            return {
              url: url,
              error: true,
            };
          }
      }
    })
  );
}

今後の課題

以下が今後対応すべき課題であると考えています。
  • 画像の読み込み最適化
    • Lighthouseで画像の読み込みが遅いといわれている…
  • App Routerへの完全移行
    • まだ色々問題があったりして本番環境では使うべきではないなどと言われていますが、今後の主流はApp Routerだと思っています
  • OGP画像の自動生成

ZUNDA では、自由で安全なデジタル・ワークプレイスを皆さまに提供すべく仲間を募集しています。皆さまのエントリーをお待ちしております。