こんにちは。microCMSでカスタマーエンジニアをしている高宮です。
店舗のチラシ(PDF)を「全店」「エリア単位」「個別店舗」に出し分けて配信する仕組みを、microCMSとNext.jsで作ってみました。
チラシには、全店で共通のものもあれば、エリアごとのセール、その店舗だけの改装セールもあります。店舗のコンテンツにチラシを持たせる作りにすると、全店に配るチラシを店舗の数だけ登録することになるので、今回はチラシ側に「どこに出すか」を持たせる形にしました。
今回作る仕組み

チラシを1件登録すると、指定した時刻に公開され、対象になっている店舗のページにだけ表示されます。
入稿作業は、店舗ページのコンテンツを触らずに、チラシのコンテンツを編集するだけで完結します。
前提
- フロントエンドにNext.js(App Router)を使います
- ファイルフィールドを利用するため、Teamプラン以上が対象です(Hobbyプランでの代替案は後述します)
microCMSの設計
APIスキーマ
3つのAPIスキーマを作成し、それぞれをコンテンツ参照フィールドで参照しています。
API | エンドポイント | 役割 |
|---|---|---|
チラシ |
| チラシ本体。PDFと、どこに配信するかを持つ |
店舗 |
| 店舗マスタ。所属エリアを参照する |
エリア |
| エリアマスタ。参照される側 |
エリア(areas)
参照されるだけの単純なマスタです。店舗がどのエリアに属するかを設定するためのAPIです。
フィールド | フィールドID | 種類 |
|---|---|---|
エリア名 |
| テキストフィールド |
スラッグ |
| テキストフィールド |
店舗(shops)
店舗マスタです。所属するエリアは店舗側から参照するようにしています。
フィールド | フィールドID | 種類 |
|---|---|---|
店舗名 |
| テキストフィールド |
住所 |
| テキストフィールド |
エリア |
| コンテンツ参照 → エリアAPI |
チラシ(flyers)
チラシAPIはPDFと配信先を持ちます。フィールドはこの5つです。
フィールド | フィールドID | 種類 |
|---|---|---|
タイトル |
| テキストフィールド |
チラシPDF |
| ファイル |
配信対象 |
| セレクトフィールド( |
対象エリア |
| 複数コンテンツ参照 → エリアAPI |
対象店舗 |
| 複数コンテンツ参照 → 店舗API |
対象エリアと対象店舗を「複数コンテンツ参照」で選択できるようにしています。1枚のチラシを複数のエリアや店舗へ配信できる必要があるので、こちらを使います。
入稿方法
表示するPDFを選択する
ファイルフィールドを利用して配信するチラシを選択します。Hobbyプランの場合はファイルフィールドが利用できないため、画像フィールドでの代替となります。
配信対象を選択する
選択肢は「全店(all)」「エリア単位(by_area)」「個別店舗(by_shop)」の3つです。

配信対象を1つのセレクトフィールドにまとめたので、1枚のチラシで3通りの配り方を表現できます。
配信対象 | 渋谷店(東京) | 新宿店(東京) | 横浜店(神奈川) | |
|---|---|---|---|---|
秋の大感謝祭 | 全店( | 表示 | 表示 | 表示 |
東京エリア 週末タイムセール | エリア単位( | 表示 | 表示 | — |
渋谷店 リニューアルオープン | 個別店舗( | 表示 | — | — |
表示側は、選ばれた値をそのまま絞り込み条件に使います。選択肢がそのままAPIのレスポンスに載るので、all / by_area / by_shop という、条件にそのまま書ける文字列にしました。
対象エリアを選ぶ
配信対象が by_area の場合、どのエリアに配信するのかを選択します。店舗APIからエリアを参照しているので、エリアを1つ選ぶだけで、そこに属する店舗すべてが配信先になります。

※ by_area を選んだ上でエリアを未選択のままにすると、そのチラシはどの店舗ページにも表示されません。現在の仕様では他のフィールドの入力状況に合わせてバリデーションを動的に切り替える機能がないため、説明文などを利用して運用側でカバーします。
対象店舗を選ぶ
配信対象が by_shop の場合、どの店舗に配信するのかを選択します。

※ by_shop を選んだ上で店舗を未選択のままにすると、そのチラシはどの店舗ページにも表示されません。対象エリアと同じく、説明文などを利用して運用側でカバーします。
※ 個別店舗の指定は少数への配信を想定した設計です。指定する店舗が増えると管理コストにもパフォーマンスにも影響するため、エリアのような別のグルーピングを設けることを推奨します。
チラシの公開日時・公開終了日時を予約する
公開開始と公開終了は、スケジュール機能で設定します。
公開開始を指定したチラシはその時刻まで下書きのまま待ち、終了を指定しておけば期間が過ぎたときに下がります。
編集するのはチラシのコンテンツだけです。配信先を何店舗選んでも、店舗やエリアのコンテンツには触れません。
表示側を作る
作る画面は店舗一覧と店舗ページの2つです。店舗一覧は shops を並べるだけなので、ここでは店舗ページを見ていきます。
やることは主に2つで、店舗を1件取って、その店舗に出すチラシを取るだけです。
import { createClient } from "microcms-js-sdk";
export const client = createClient({
serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN!,
apiKey: process.env.MICROCMS_API_KEY!,
});
// 店舗を1件取る。参照している area も一緒に返ってくる
export async function getShop(shopId: string): Promise<Shop> {
return client.getListDetail<Shop>({ endpoint: "shops", contentId: shopId });
}
// この店舗に出すチラシを、3条件のORで取る
export async function getFlyersForShop(shop: Shop): Promise<Flyer[]> {
const conditions = [
`targetType[contains]all`, // 全店配布
`targetShops[contains]${shop.id}`, // この店舗を名指ししている
];
if (shop.area?.id) {
conditions.push(`targetAreas[contains]${shop.area.id}`); // この店舗のエリア
}
const res = await client.getList<Flyer>({
endpoint: "flyers",
queries: {
limit: 10,
filters: conditions.join("[or]"),
orders: "-publishedAt",
fields: "id,title,pdf,targetType",
},
});
return res.contents;
}
export const dynamic = "force-dynamic";
export default async function ShopDetailPage({
params,
}: {
params: Promise<{ shopId: string }>;
}) {
const { shopId } = await params;
const shop = await getShop(shopId); // 店舗を1件
const flyers = await getFlyersForShop(shop); // この店舗に出すチラシ
return (
<>
<h1>{shop.name}</h1>
<div className="flyer-grid">
{flyers.map((flyer) => (
<FlyerCard key={flyer.id} flyer={flyer} />
))}
</div>
</>
);
}
チラシの出し分けのロジックはアプリ側にありません。どのチラシを出すかは、getFlyersForShop が組み立てる絞り込み条件だけで決まります。
ここでは2つの取得を順に待っていますが、店舗名を先に出してチラシだけあとから流し込む、という形にもできます。Suspenseで境界を分けることになり、そのときはチラシ側でも店舗が必要になるので、getShop を React の cache() で包んでおくと、同じ店舗を2回取りに行かずに済みます。
どのチラシを出すかを絞り込む
取得するのは「この店舗に表示すべきチラシ」で、条件は設計そのままの3つのOR条件です。
- 配信対象が「全店」
- 対象店舗にこの店舗が含まれる
- 対象エリアにこの店舗のエリアが含まれる
microCMSの絞り込みは [or] と [contains] を組み合わせてこれを表現できます。さきほどの conditions を join("[or]") でつなぐと、渋谷店を開いたときは次の文字列になります。
filters=targetType[contains]all[or]targetShops[contains]shibuya[or]targetAreas[contains]tokyo
詳しい演算子は APIリクエストのクエリパラメータ と コンテンツの取得(一覧) にまとまっています。
切り替わりは時刻で起きる
公開開始・公開終了の時刻が来ると、microCMS側でコンテンツのステータスが変わります。アプリ側は関与しません。
公開が終了したコンテンツは、コンテンツAPIのレスポンスに含まれません。そのため絞り込み条件に日付は入っていませんし、「まだ掲載期間内か」を判定する処理もありません。返ってきたものをそのまま並べているだけです。

キャッシュについては注意です。切り替わりは microCMS側で勝手に起きるイベントなので、アプリにはその瞬間を知る手段がありません。入稿のタイミングで再検証をかけたところで、時刻が来た瞬間には何も起きません。そのため、店舗ページは force-dynamic にして、アクセスのたびに引く形にしています。
ここをISRにするなら、再検証の間隔ぶんだけ切り替えが遅れることを許容するか、Webhook でオンデマンド再検証する設計にすることになります。
注意事項
PDFを扱うにはTeamプラン以上が必要
チラシをPDFで持たせている都合上、この記事の構成はTeamプラン以上が前提です。ファイルアップロードがHobbyプランでは使えません。
Hobbyプランで同じことをやるなら、チラシを画像で持たせる形になります。画像フィールドは全プランで使えますし、紙面を1枚見せるだけなら画像で足ります。表示側も <img> を並べるだけで済みます。
掲載が終わったチラシは残り続ける
公開停止になったチラシは、店舗ページから消えるだけでmicroCMSにはコンテンツ自体は残ります。長く運用すると、管理画面が終了済みのチラシで埋まっていきます。
これは裏を返せば、過去にどのチラシをどの店舗へ出したかが残るということでもあります。掲載履歴として使うなら消さずに残し、アーカイブ用のフィールドを足して一覧から外します。履歴が要らないなら、定期的に削除する運用を決めておきます。
実運用に合わせてさらに機能を拡張するなら
今回の設計では配信の軸はエリアの1つだけ、出し分けは全店・エリア・店舗の3パターンです。ここから先、たとえば次のようなことをやりたくなったら、フィールドを足すことになります。
- エリア単位の配信から特定の店舗だけ外す:除外用の複数参照フィールドを足し、絞り込みの末尾に除外条件をつなぐ
- 表示順を決める:優先度の数値フィールドを足して、並び順の指定に加える
- 配信の軸を増やす:業態別・規模別など、軸が1つ増えるごとに絞り込みのORが1本増える
- 同じチラシを期間を空けて2回出す:公開予約は1つのコンテンツにつき1セットなので、コンテンツを分ける
また、入稿作業も、1件ずつ登録するぶんには管理画面で対応できます。ただし、複数のチラシを複数の店舗に一括で登録し、それぞれに公開予約まで付けたいとなると、マネジメントAPIを使った独自実装が必要になります。
おわりに
題材はチラシでしたが、あるコンテンツを複数の対象に出し分ける形は、会員ランク別のお知らせ、拠点別の求人、支店ごとの営業案内等でも同じです。
配信するコンテンツを対象側に直接持たせず、独立したAPIから参照させたことで1件の登録が複数の対象に届き、対象側のコンテンツには書き込みが発生しません。
出し分けの軸をいくつ持つか、除外や優先度をどこまで表現するかは、運用する人数や更新の頻度で変わります。この構成をそのまま使うというより、どこまで持たせるかを考えるときの材料にしてもらえればと思います。