a-blog cms をヘッドレスCMSとして Next.js でサイト制作するときのポイント 2025

公開日:

目次

この記事は a-blog cms Advent Calendar 2025 の19日目の記事です。

2023 年の a-blog cms アドベントカレンダーで「a-blog cms をヘッドレスCMSとして Next.js でサイト制作するときのポイント」という記事を書きました。

https://blog.uidev.jp/blog/headless-a-blogcms.html

当時は、a-blog cms のバージョンが 3.1 だったこともあり、ヘッドレスCMSとして使うことは「可能だが、工夫が必要」な状態でした。しかし、Ver. 3.2 の登場により、その状況は一変しました。

今回は、2025年現在の視点で、a-blog cms Ver. 3.2 の新機能を活用し、Next.js でサイト制作を行う際のポイントをまとめます。また、それに合わせて自作の SDKである acms-js-sdk をアップデートし、このブログ自体の実装も刷新したので、実際のコードを交えて紹介します。

結論:Ver. 3.2 で開発者体験(DX)は劇的に向上した

まず結論から言うと、Ver. 3.2 以前と以後では、フロントエンド開発の快適さが全く違います。これまでの課題だった「CMS特有のデータの扱いづらさ」が解消され、モダンなフロントエンドフレームワークと素直に連携できるようになりました。

具体的なポイントは以下の3点です。

  1. V2 モジュール で「フロントエンドのためのJSON」が手に入る

  2. V2_Entry_Body で本文をHTMLとして取得し、実装コストを下げる

  3. ASSETS_DELIVERY_URL で画像パス問題から解放される


ポイント1:API v2 で「フロントエンドのためのJSON」を手に入れる

以前の課題:扱いづらいJSON

以前から API 機能自体は存在していましたが、レスポンスされる JSON は CMS のテンプレート変数の構造に強く依存していました。 ネストが深すぎたり、配列とオブジェクトの構造が直感的でなかったりと、そのままプログラムから使うには不向きでした。そのため、これまでは「扱いやすい JSON を出力するためだけのテンプレート」を CMS 側で実装するという、本末転倒な作業が発生していました。

また、このブログではエントリー一覧とエントリー詳細については拡張アプリで独自に実装したモジュールを利用して対応していました。

Ver. 3.2 の改善:API v2 (V2モジュール)

Ver. 3.2 で追加された V2 モジュール が、この問題を解決してくれます。

V2 モジュールについても既存の API 機能と同じくモジュールが出力するデータを API として JSON で出力することが可能です。

使い方は下記のようになります。

管理画面 > コンフィグ設定 > API設定 から API(v2)を有効化します。

API設定画面
API設定画面

V2 モジュールのAPI機能を利用する場合にはエンドポイントに api/v2/:module_id を含める必要があります。

https://example.com/api/v2/モジュールID名/

V2 モジュールの API は開発者が期待する「整理された JSON」が標準で返ってくるため、データ整形のための複雑なマッパー関数や、専用テンプレートの作成が不要になります。

「欲しいデータが、欲しい形で返ってくる」。これだけで実装工数は大幅に削減されます。


ポイント2:本文は V2_Entry_Body で HTML として受け取る

ヘッドレスCMSのフロントエンド開発で最も頭を悩ませるのが「本文(リッチテキストやユニット)」の扱いです。 Ver. 3.2 未満の a-blog cms では「ユニット」のデータを全て JSON オブジェクトとして受け取り、フロントエンド側で switch 文などを使って表示する必要があり、開発コストも保守コストもかかってしまうという課題がありました。

しかし、Ver. 3.2 の V2_Entry_Body モジュールを利用すると、エントリーのユニット部分を CMS がレンダリングした HTML として取得できます。

  • タイトル・日付・カテゴリ・カスタムフィールド: API v2 の JSON を使い、フロントエンドで自由にHTMLを組み立てる。

  • 本文(ユニット): V2_Entry_Body から HTML を受け取り、dangerouslySetInnerHTML 等で表示する

これにより、a-blog cms 側で新しいユニットが追加されたり仕様が変わったりしても、フロントエンド側の改修は不要になります。a-blog cms の強力な編集機能を生かしつつ、開発コストや保守コストを抑えることができます。

実際にこのブログでは下記の様に実装しました。データがHTML文字列として渡ってくるため、html-react-paser というライブラリでHTMLを自由に加工することもできます。

export default function EntryBody({ html }: Props) {
  const slides: Slide[] = [];

  const options: HTMLReactParserOptions = {
    replace(domNode) {
      if (domNode instanceof Element && domNode.attribs) {
        if (domNode.tagName === 'a') {
          const { href, ...rest } = domNode.attribs;

          // Check if anchor contains an image
          const imgNode = findNode(
            domNode,
            (n) => n instanceof Element && n.tagName === 'img',
          ) as Element | undefined;

          if (imgNode) {
            const {
              src,
              alt = '',
              width,
              height,
              ...imgRest
            } = imgNode.attribs;

            if (isConfiguredHost(src)) {
              const fill = width === '' || height === '';
              const w = width !== '' ? parseInt(width, 10) : undefined;
              const h = height !== '' ? parseInt(height, 10) : undefined;

              slides.push({
                src,
                width: w,
                height: h,
                alt,
              });
              const index = slides.length - 1;

              // Replace the anchor with LightboxTrigger (button), preventing a > button nesting
              return (
                <LightboxTrigger index={index} className="cursor-pointer">
                  <Image
                    fill={fill}
                    src={src}
                    alt={alt}
                    width={w}
                    height={h}
                    {...attributesToProps(imgRest)}
                  />
                </LightboxTrigger>
              );
            }
          }

          return (
            <Link href={href} {...attributesToProps(rest)}>
              {domToReact(domNode.children as DOMNode[], options)}
            </Link>
          );
        }
        if (domNode.tagName === 'img') {
          const { src, alt = '', width, height, ...rest } = domNode.attribs;
          // Only use Next.js Image for configured host
          if (isConfiguredHost(src)) {
            const fill = width === '' || height === '';
            const w = width !== '' ? parseInt(width, 10) : undefined;
            const h = height !== '' ? parseInt(height, 10) : undefined;

            slides.push({
              src,
              width: w,
              height: h,
              alt,
            });
            const index = slides.length - 1;

            return (
              <LightboxTrigger index={index} className="cursor-pointer">
                <Image
                  fill={fill}
                  src={src}
                  alt={alt}
                  width={w}
                  height={h}
                  {...attributesToProps(rest)}
                />
              </LightboxTrigger>
            );
          }
        }
        if (
          domNode.tagName === 'table' &&
          domNode.children.find(
            (child) => child instanceof Element && child.tagName === 'tbody',
          ) === undefined
        ) {
          return (
            <table {...attributesToProps(domNode.attribs)}>
              <tbody>
                {domToReact(domNode.children as DOMNode[], options)}
              </tbody>
            </table>
          );
        }
        if (domNode.attribs.class?.includes('column-embed')) {
          const anchor = findNode(
            domNode,
            (n) => n instanceof Element && n.tagName === 'a',
          ) as Element | undefined;

          if (anchor) {
            const titleNode = findNode(
              anchor,
              (n) =>
                n instanceof Element &&
                !!n.attribs?.class?.includes('quoteTitle'),
            );
            const siteNameNode = findNode(
              anchor,
              (n) =>
                n instanceof Element &&
                !!n.attribs?.class?.includes('quoteSiteName'),
            );
            const descriptionNode = findNode(
              anchor,
              (n) =>
                n instanceof Element &&
                !!n.attribs?.class?.includes('quoteDescription'),
            );
            const imageNode = findNode(
              anchor,
              (n) => n instanceof Element && n.tagName === 'img',
            ) as Element | undefined;

            const title = titleNode ? getText(titleNode) : '';
            const siteName = siteNameNode ? getText(siteNameNode) : '';
            const description = descriptionNode ? getText(descriptionNode) : '';
            const imageSrc = imageNode?.attribs?.src || '';
            const { href } = anchor.attribs;

            return (
              <div className="not-format my-[1.5em] sm:my-[2em] lg:my-[1.7777778em]">
                <RichLink
                  href={href}
                  title={title}
                  siteName={siteName}
                  description={description}
                  imageSrc={imageSrc}
                  target="_blank"
                  rel="noopener noreferrer"
                />
              </div>
            );
          }
        }
      }
    },
  };

  const content = parse(html, options);

  return <LightboxProvider slides={slides}>{content}</LightboxProvider>;
}

ポイント3:画像パス問題は ASSETS_DELIVERY_URL で解決

以前の課題:相対パス問題

地味ながら開発者のストレスになっていたのが画像のパスです。 API から返ってくるパスは /media/001/202512/img.jpg のような相対パスでした。これをローカル環境や Vercel などの別ドメインで表示しようとするとリンク切れになるため、クライアント側でドメインを付与する処理を書く必要がありました。

<Image
  src={new URL(media.path, MEDIA_BASE_URL).toString()}
  // other props
/>

Ver. 3.2 の改善:設定一つで画像配信専用ドメインからの配信が可能に

Ver. 3.2 の V2 モジュールでは API 経由で出力するとメディアやアーカイブなどのアセットURLが絶対パスに変換されて出力されます。

これにより、フロントエンド側の「画像パス書き換えロジック」を全削除できます。

また、a-blog cms 設置ディレクトリの .env ファイルで ASSETS_DELIVERY_URL を設定するだけで、API レスポンスの画像パスに含まれるドメインを任意のドメインにすることができます。

# ストレージ設定
ASSETS_DELIVERY_URL=https://assets.uidev.jp # CMSのドメインとS3配信URLが違う場合は配信URLを指定します(例: https://assets.example.com)

この機能を利用することで画像配信専用ドメインを作って、CMSサーバーを隠すことが簡単にできるようになります。

この機能は公式ドキュメントでプロフェッショナルライセンス以上の機能と紹介されていますが、スタンダードライセンスで利用することが可能です。(プロフェッショナルライセンスが必要なのは AWS S3 をストレージとして利用する場合です)

https://developer.a-blogcms.jp/document/professional/aws-s3.html


acms-js-sdk の API (v2) 対応

Ver. 3.2 に合わせて、個人で開発している acms-js-sdk もアップデートを行いました。

https://github.com/uidev1116/acms-js-sdk

acms-js-sdk を API v2 のエンドポイントに対応させました。

具体的には、createClient 関数の acmsPathOptions 引数に apiVersion を指定できるようになりました。

const acmsClient = createClient({
  baseUrl: 'YOUR_BASE_URL',
  apiKey: 'YOUR_API_KEY',
  acmsPathOptions: {
    apiVersion: 'v2', // 'v1' or 'v2' (default: 'v2')
  },
});

// すべてのAPIリクエストが指定されたバージョン(v2)を利用するようになります。
acmsClient.get({
  api: 'summary_index',
});
// => リクエスト先URLは YOUR_BASE_URL/api/v2/summary_index/ になります。

Ver. 0.3.0 よりapiVersion は v2 がデフォルトになっているため、以下のように書くだけで API (v2) を利用できます。

import { createClient } from '@uidev1116/acms-js-sdk';

const acmsClient = createClient({
  baseUrl: 'YOUR_BASE_URL',
  apiKey: 'YOUR_API_KEY',
});

const { data } = await acmsClient.get(
  { blog: 'blog', api: 'summary_index' },
);

const { items: entries = [], pagination } = data;

console.log({ entries, pagination });

また、互換性目的などで、既存の API (v1) を特定の箇所だけ使いたい場合には、 get メソッドのオプションで上書きすることも可能です。

acmsClient.get(
  { api: 'summary_index' },
  { acmsPathOptions: { apiVersion: 'v1' } },
);
// => リクエスト先URLは YOUR_BASE_URL/api/summary_index/ になります。

まとめ

a-blog cms Ver. 3.2 は、ヘッドレスCMSとしての利用を考えている開発者にとって、非常に強力なアップデートです。

  • API v2 できれいな JSON が手に入る

  • V2_Entry_Body で実装コストを下げる

  • ASSETS_DELIVERY_URL で面倒なパス処理をなくす

これから a-blog cms でヘッドレス構成を検討している方、あるいは以前の API で苦労した経験がある方は、ぜひ Ver. 3.2 と acms-js-sdk を試してみてください。2025年の今なら、非常に快適な開発体験が得られるはずです。

明日の a-blog cms Advent Calendar 2025 20日目 は、永富敬千さんの記事です。是非お楽しみに!

おまけ

画像の Lightbox として SmartPhoto を利用していたのですが、SPA と相性が悪く、yet-another-react-lightbox というライブラリに移行しました。

SmartPhoto だとページ遷移するたびに毎回 SmartPhoto が実行されてしまい、1度写真をクリックすると何度もクリックしないと閉じられなくなるという問題が発生したためです。

SmartPhoto は MPA ように実装されているライブラリなので いわゆる destroy 処理がなく、1度実行した SmartPhoto を破棄することができないのが原因でした。

yet-another-react-lightbox に移行することで問題なく画像の Lightbox が実装できましたので、おすすめです。