microCMSでnote的なサービスを作るなら(part2 記事の購入判断ロジック検討編 Next.jsのISRでどうする?)
本記事のサマリ
ISRで静的生成したページと、ユーザーごとの認可処理をどう両立させるか。3つのパターンを検討した結果、「外枠はISR、本文だけ動的取得」という構成が最適解だと判断しました。Next.jsのServer Componentを使えば、静的生成と動的認可を同じページで組み合わせることができます。
前回のおさらい:課金設計の責務分離
前回の記事では、microCMSでnote的なサービスを作る際の課金設計について整理しました。記事の価格はCMSに、購入履歴はDBに、決済処理はStripeに。この責務分離が設計の軸になるという話でしたね。
今回はその続きとして、「認可処理をどこに置くか」という問題を考えていきます。データの責務分離ができても、処理の責務分離ができていなければ、結局は破綻してしまいますからね。
認可設計の根本的な問い
具体的な要件を整理すると、ISRで静的生成している記事ページで、ユーザーごとに「購入済みかどうか」を判定して有料記事の本文を安全に出し分けたい、ということです。
この要件から浮かび上がる根本的な問いが2つあります。
- ISRの中で認可(購入判定)はできるのか?
- microCMSへの問い合わせは毎回必要なのか?
結論を先に言ってしまうと、ISRでは認可はできません。ISRは「誰が見るか」を考慮せずに静的生成する仕組みですから、これは当然といえば当然です。また、microCMSへの全文問い合わせは「購入済みのときだけ」にすべきです。セキュリティとAPI消費量の両面で、これが正解になります。
この前提を踏まえて、どんな設計パターンが考えられるのか検討してみました。
検討したパターン:3つの選択肢
パターンB(却下):ISR時に全文取得、フロントで出し分け
ISR時に記事全文をmicroCMSから取得しておいて、未購入ユーザーはフロント側で本文を隠すパターンです。
これは論外ですね。ブラウザの開発者ツールで通信を見れば本文が丸見えですし、そもそもHTMLソースに含まれている時点でアウトです。有料記事として成立しません。
パターンC(単純だが弱い):完全に動的生成
逆に、ISRを諦めてページを完全に動的生成するという選択肢もあります。毎リクエストでDB接続して認可チェックを行い、OKなら同時にmicroCMSから記事全文を取得する流れです。
セキュリティ的には確実ですし、実装もわかりやすい。ただし、初速表示が遅くなりがちですし、SEOとキャッシュ効率が悪化します。note的なメディア構成では、記事ページの初速表示とSEOは重要な要素なので、これは大きな妥協になります。
確実だけど、パフォーマンス面での代償が大きいパターンですね。
パターンA(採用候補):外枠はISR、本文だけ動的取得
そこで第3の選択肢として、ページの外枠(タイトル、導入文、価格など)はISRで静的生成しつつ、本文だけを「認可付きで動的取得」するという構成を考えました。妥当な線ですね👍
これが今回の採用候補です。ISRのメリット(初速表示、SEO)と、動的認可のメリット(セキュリティ)を両立できる可能性があります。
パターンAの具体構成
役割分担の整理
パターンAでの各コンポーネントの役割は以下のようになります。
microCMSは記事本文の保管場所として機能しますが、直接公開はしません。タイトル・導入文・価格などの公開情報も管理しますが、これらは本文とは別のAPIエンドポイントで提供します。
**Next.js(ISR)**は記事ページの外枠を静的生成し、SEOと初速表示を担当します。ここでは「誰が見るか」は一切考慮せず、公開情報だけを扱います。
Next.js API / Server Componentが認証チェック、購入・サブスク判定を行い、OKのときだけmicroCMSに本文を取りに行きます。この部分が「門番」の役割を果たすことになります。
**DB(Supabase / KV など)**は購入履歴とサブスクリプション状態を管理します。
この分離は、前回記事で整理した責務分離の延長線上にあります。データの責務だけでなく、処理の責務も適切に分離することで、全体の設計が一貫したものになります。
リクエストの流れ
実際のリクエスト処理は以下のような流れになります。
- ユーザーが記事ページにアクセス
- ISR済みのページが即座に表示される(タイトル、導入文、価格)
- 同時に本文取得処理が開始される
- サーバー側で認証・認可の判定を実行
- ログインしているか?
- 該当記事を購入済みか、またはサブスクリプション有効か?
- 判定結果に応じた分岐処理
- OK → microCMSから本文取得して返却
- NG → microCMSには問い合わせず、購入導線を返却
この流れだと、ページの基本構造は瞬時に表示されつつ、本文部分だけが少し遅れて表示されることになります。UX的にも自然ですし、技術的にも理にかなっています。
パターンAの実装:ISRと動的取得の組み合わせ
ここからが本題です。「ISRで外枠、本文は動的取得」というのは具体的にどう実装するのか。Next.jsのApp Routerでは、Server Componentを使うことで実現できます。
基本的な仕組み
Next.jsの公式ドキュメントによると、App Routerでは同じページ内で静的生成と動的レンダリングを混在させることができます。
重要なのは、Server Componentはデフォルトでキャッシュされるが、動的関数(cookies()、headers()など)を使うとその部分だけ動的レンダリングになるという点です。
実装例:記事ページの構成
具体的なコード例を見てみましょう。
// app/articles/[id]/page.tsx
import { getArticleMetadata } from '@/lib/microcms'
import { ArticleContent } from './article-content'
// この関数でISRを有効化
export const revalidate = 3600 // 1時間ごとに再生成
type Props = {
params: Promise<{ id: string }>
}
export default async function ArticlePage({ params }: Props) {
const { id } = await params
// 公開情報(タイトル、導入文、価格)はISRでキャッシュ
const metadata = await getArticleMetadata(id)
return (
<div>
<h1>{metadata.title}</h1>
<p>{metadata.introduction}</p>
<p>価格: ¥{metadata.price}</p>
{/* 本文は別のServer Componentで動的取得 */}
<ArticleContent articleId={id} />
</div>
)
}
// app/articles/[id]/article-content.tsx
import { cookies } from 'next/headers'
import { checkPurchase } from '@/lib/db'
import { getArticleBody } from '@/lib/microcms'
export async function ArticleContent({ articleId }: { articleId: string }) {
// cookies()を使うとこのコンポーネントだけ動的レンダリングになる
const cookieStore = await cookies()
const userId = cookieStore.get('userId')?.value
if (!userId) {
return <div>ログインして購入してください</div>
}
// DBで購入済みかチェック
const hasPurchased = await checkPurchase(userId, articleId)
if (!hasPurchased) {
return <div>この記事を購入してください</div>
}
// 購入済みの場合のみmicroCMSから本文取得
const body = await getArticleBody(articleId)
return (
<div dangerouslySetInnerHTML={{ __html: body }} />
)
}
ポイント解説
この実装の肝は、ArticleContentコンポーネントで cookies()を使っているところです。
Next.jsでは、cookies()や headers()などの動的関数を使うと、そのコンポーネントは毎リクエスト時に実行されるようになります。一方、親コンポーネントの ArticlePageは revalidateを指定しているため、ISRでキャッシュされます。
つまり、タイトルや導入文は静的生成されてキャッシュされますが、本文部分だけは毎回認可チェックが走る、という構成が実現できるわけです。
この仕組みは、公式ドキュメントの「Server Components」のページで詳しく説明されています。
設計上の重要ポイント
microCMSのAPI設計
この実装を機能させるには、microCMS側のフィールド設計が重要です。公開情報と本文を分けておく必要があります。
// 公開情報取得(ISRでキャッシュ)
async function getArticleMetadata(id: string) {
const response = await fetch(
`https://YOUR_SERVICE.microcms.io/api/v1/articles/${id}?fields=id,title,introduction,price`,
{
headers: { 'X-MICROCMS-API-KEY': process.env.MICROCMS_API_KEY! },
next: { revalidate: 3600 } // ISR
}
)
return response.json()
}
// 本文取得(認可後のみ実行)
async function getArticleBody(id: string) {
const response = await fetch(
`https://YOUR_SERVICE.microcms.io/api/v1/articles/${id}?fields=body`,
{
headers: { 'X-MICROCMS-API-KEY': process.env.MICROCMS_API_KEY! },
cache: 'no-store' // キャッシュしない
}
)
const data = await response.json()
return data.body
}
fieldsパラメータで取得するフィールドを絞ることで、公開情報と本文を明確に分離できます。
セキュリティの原則
ブラウザからmicroCMSを直接呼ばせないこと。これは絶対です。APIキーをフロントエンドに持たせるのは、セキュリティ上非常に危険です。
上記の実装例では、すべてのmicroCMS APIコールがServer Component内で完結しているため、APIキーがクライアントに露出することはありません。これが前回記事で触れたセキュリティの原則と一致しています。
ISRと認可の責務分離
ISRは「表紙」、認可チェックは「門番」、microCMSは「金庫」。門番を通過したときだけ、金庫を開ける。この比喩で整理すると分かりやすいですね。
ISRでは「誰が見るか」を一切考えず、認可はServer Componentで行う。この切り分けが設計の肝です。
Client Componentでの実装パターン
もう一つの選択肢として、Client Componentでフェッチする方法もあります。
// app/articles/[id]/article-content-client.tsx
'use client'
import { useEffect, useState } from 'react'
export function ArticleContentClient({ articleId }: { articleId: string }) {
const [content, setContent] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`/api/articles/${articleId}/content`)
.then(res => res.json())
.then(data => {
setContent(data.body)
setLoading(false)
})
}, [articleId])
if (loading) return <div>読み込み中...</div>
if (!content) return <div>購入してください</div>
return <div dangerouslySetInnerHTML={{ __html: content }} />
}
// app/api/articles/[id]/content/route.ts
import { cookies } from 'next/headers'
import { checkPurchase } from '@/lib/db'
import { getArticleBody } from '@/lib/microcms'
import { NextRequest } from 'next/server'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const cookieStore = await cookies()
const userId = cookieStore.get('userId')?.value
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const hasPurchased = await checkPurchase(userId, id)
if (!hasPurchased) {
return Response.json({ error: 'Not purchased' }, { status: 403 })
}
const body = await getArticleBody(id)
return Response.json({ body })
}
この方法だと、初期HTMLには本文が含まれず、クライアント側でフェッチすることになります。Server Componentの方がシンプルですが、ローディング状態を細かく制御したい場合はこちらも選択肢です。
まとめ
ISRと認可を両立させるには、Server Componentで動的関数を使うのが最もシンプルです。cookies()や headers()を使えば、その部分だけ動的レンダリングになり、認可チェックを挟むことができます。
前回の「データの責務分離」(価格はCMS、購入履歴はDB)と今回の「処理の責務分離」(公開情報はISR、本文は動的取得)が一貫していて、設計として筋が通っているのではないでしょうか。
次回は実際に動くコードを書いて、この設計を検証していきます👍
株式会社StellarCreate(stellar-create.co.jp)のエンジニアブログです。 プロダクト指向のフルスタックエンジニアを目指す方募集中です! カジュアル面談で気軽に雑談しましょう!→ recruit.stellar-create.co.jp/
Discussion