💳

microCMSでnote的なサービスを作るなら(part2 記事の購入判断ロジック検討編 Next.jsのISRでどうする?)

に公開

本記事のサマリ

ISRで静的生成したページと、ユーザーごとの認可処理をどう両立させるか。3つのパターンを検討した結果、「外枠はISR、本文だけ動的取得」という構成が最適解だと判断しました。Next.jsのServer Componentを使えば、静的生成と動的認可を同じページで組み合わせることができます。

前回のおさらい:課金設計の責務分離

前回の記事では、microCMSでnote的なサービスを作る際の課金設計について整理しました。記事の価格はCMSに、購入履歴はDBに、決済処理はStripeに。この責務分離が設計の軸になるという話でしたね。

https://zenn.dev/stellarcreate/articles/microcms-note-stripe-part1

今回はその続きとして、「認可処理をどこに置くか」という問題を考えていきます。データの責務分離ができても、処理の責務分離ができていなければ、結局は破綻してしまいますからね。

認可設計の根本的な問い

具体的な要件を整理すると、ISRで静的生成している記事ページで、ユーザーごとに「購入済みかどうか」を判定して有料記事の本文を安全に出し分けたい、ということです。

この要件から浮かび上がる根本的な問いが2つあります。

  1. ISRの中で認可(購入判定)はできるのか?
  2. 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 など)**は購入履歴とサブスクリプション状態を管理します。

この分離は、前回記事で整理した責務分離の延長線上にあります。データの責務だけでなく、処理の責務も適切に分離することで、全体の設計が一貫したものになります。

リクエストの流れ

実際のリクエスト処理は以下のような流れになります。

  1. ユーザーが記事ページにアクセス
  2. ISR済みのページが即座に表示される(タイトル、導入文、価格)
  3. 同時に本文取得処理が開始される
  4. サーバー側で認証・認可の判定を実行
    • ログインしているか?
    • 該当記事を購入済みか、またはサブスクリプション有効か?
  5. 判定結果に応じた分岐処理
    • OK → microCMSから本文取得して返却
    • NG → microCMSには問い合わせず、購入導線を返却

この流れだと、ページの基本構造は瞬時に表示されつつ、本文部分だけが少し遅れて表示されることになります。UX的にも自然ですし、技術的にも理にかなっています。

パターンAの実装:ISRと動的取得の組み合わせ

ここからが本題です。「ISRで外枠、本文は動的取得」というのは具体的にどう実装するのか。Next.jsのApp Routerでは、Server Componentを使うことで実現できます。

基本的な仕組み

Next.jsの公式ドキュメントによると、App Routerでは同じページ内で静的生成と動的レンダリングを混在させることができます。

https://nextjs.org/docs/app/building-your-application/rendering/server-components

重要なのは、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でキャッシュされます。

https://nextjs.org/docs/app/api-reference/functions/cookies

つまり、タイトルや導入文は静的生成されてキャッシュされますが、本文部分だけは毎回認可チェックが走る、という構成が実現できるわけです。

この仕組みは、公式ドキュメントの「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パラメータで取得するフィールドを絞ることで、公開情報と本文を明確に分離できます。

https://microcms.io/docs/api

セキュリティの原則

ブラウザから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、本文は動的取得)が一貫していて、設計として筋が通っているのではないでしょうか。

次回は実際に動くコードを書いて、この設計を検証していきます👍

https://nextjs.org/docs/app/building-your-application/rendering/server-components

https://nextjs.org/docs/app/api-reference/functions/cookies

https://microcms.io/docs/api

株式会社StellarCreate | Tech blog📚

Discussion