2014 年から個人でアプリを作ってきて、累計 5,000 万ダウンロードを超えたあたりから「アプリだけでなく、その周辺にあるドキュメントや知見も静かに作品化していきたい」と考えるようになりました。Claude Lab を含む 4 サイトでメンバーシップ記事を運用しはじめてから一番悩んだのが、検索エンジンにペイウォール領域をどう申告するか、という地味で本質的な問題です。今回は、Next.js App Router と Stripe で作った有料記事ページに Google の Flexible Sampling を組み込んだときの判断と実装を、淡々と書き残しておきます。
なぜ Flexible Sampling という選択肢にたどり着いたか
最初に作ったときの構成は、とてもシンプルでした。サーバー側で会員 Cookie の有無を見て、非会員には冒頭だけ HTML を返し、ペイウォール以降は完全に伏せる。これだと「Googlebot にも会員にも同じ HTML が出ているなら問題ない」と思いがちですが、実際に運用してみると 2 つの違和感が出てきました。
ひとつは、検索流入が伸び悩むことです。サイト全体としては丁寧に書いているつもりでも、Google から見える本文がプレビュー部分だけになってしまうので、長文の有料記事ほど評価されにくくなります。もうひとつは、Cookie の有無で出すコンテンツの量を切り替える設計が、状況によっては Google から cloaking と受け取られる可能性があることでした。明示的に「ここから先は有料」と申告する仕組みがないままだと、何かのきっかけでサイト全体の信用が下がりかねません。
そこで採用したのが Google Flexible Sampling の公式ドキュメント で案内されている、Schema.org の hasPart と cssSelector を使ったペイウォール宣言です。Googlebot には全文 HTML を返すが、会員以外のブラウザでは CSS で読めないようにする、という単純な仕掛けで、cloaking 扱いされる余地をなくしながら、検索評価の対象を本文全体に広げられます。
Schema.org の hasPart が伝えていること
Flexible Sampling の中心になるのは、JSON-LD で記事の「どこからが有料か」を構造化データとして明示する作業です。私のサイトでは、Article スキーマの中に次のような hasPart を一緒に出すようにしました。
const articleJsonLd = {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.meta.title,
"datePublished": article.meta.date,
"author": { "@type": "Person", "name": "廣川 政樹" },
"isAccessibleForFree": article.meta.premium ? "False" : "True",
...(article.meta.premium && {
hasPart: {
"@type": "WebPageElement",
"isAccessibleForFree": "False",
"cssSelector": ".paywall-hidden-content",
},
}),
};ポイントは三つあります。isAccessibleForFree を有料記事では明示的に "False" にすること。hasPart の cssSelector で、ペイウォール対象のクラス名を一つ宣言すること。そして、HTML 側でそのクラスを必ず使うこと。Googlebot はこの宣言をもとに「このセレクタの内側は有料領域だから、無料セッションには表示されていなくても普通のことだ」と理解してくれます。
cssSelector に書く値は HTML 側の構造と必ず一致させます。私は .paywall-hidden-content という単語そのままの名前を選びました。後から CSS の命名規則を変えたくなったときも、一箇所だけ書き換えれば済むようにするためです。
Next.js App Router での preview + paywall 構造
サーバーコンポーネントの中では、会員判定と CSS クラスの使い分けを次のように整理しています。コードを短くしているので、要旨だけ伝わるように残します。
const canViewPremium = await getMembershipAccess();
const canViewArticle = canViewPremium || await getArticleAccess(slug);
const fullHtml = await getArticleContent(article.meta);
const previewHtml = (() => {
const h2s = Array.from(fullHtml.matchAll(/<h2[\s>]/g));
const cutIdx = h2s.length >= 4 ? Math.floor(h2s.length / 2) : h2s.length - 1;
return h2s[cutIdx]?.index !== undefined
? fullHtml.slice(0, h2s[cutIdx].index)
: fullHtml.slice(0, Math.floor(fullHtml.length / 2));
})();
return (
<article>
<ArticleHeader meta={article.meta} />
<Script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }} />
{canViewArticle ? (
<div className="article-content"
dangerouslySetInnerHTML={{ __html: fullHtml }} />
) : (
<>
<div className="article-content"
dangerouslySetInnerHTML={{ __html: previewHtml }} />
<div className="paywall-hidden-content"
dangerouslySetInnerHTML={{ __html: fullHtml.slice(previewHtml.length) }} />
<PremiumPaywall highlights={article.meta.highlights} />
</>
)}
</article>
);ここで大切なのは三点です。fullHtml 自体は常にサーバーから返している、つまり Googlebot に対しては全文を出していること。非会員には preview と paywall-hidden-content を分割して入れていて、後者にだけ CSS で display: none をかけていること。プレビューの切れ目を「全 H2 数の半分の手前」にしているので、短い記事でも長い記事でも、無料領域の量が極端に偏らないこと。
CSS で「読めないが存在する」状態を作る
paywall-hidden-content の中身をどう隠すかは、最初に迷ったところです。opacity: 0 で透明にする方法もありますが、テキストが選択できてしまって心理的な納得感が崩れます。visibility: hidden だと領域が残るので、レイアウトが間延びします。最終的に落ち着いたのは、display: none で完全に DOM から切り離す方法でした。
.paywall-hidden-content {
display: none;
}たった 1 行ですが、Schema.org の hasPart で「このセレクタは有料」と申告した上で実装するので、Google からは仕様通りの動きだと認識されます。display: none を使うこと自体は通常の CSS なら SEO 的に避けたい挙動ですが、ペイウォールに限ってはむしろ正しい使い方になります。
Cloudflare Workers ASSETS で配信する場合の注意
私のサイトは Next.js 16 を Cloudflare Workers で動かしていて、記事 HTML は public/content/articles/{locale}/{category}/{slug}.html に分割しておき、ASSETS バインディング経由で取得しています。Worker の 62 MiB バンドル制限に当たらないようにする仕組みです。
ここで気をつけたいのは、Worker 側で fetch() を使って自分のドメインを叩いてはいけない点です。Cloudflare Workers は自ホスト名への HTTP 呼び出しを許していないので、必ず getCloudflareContext().env.ASSETS.fetch() を経由します。getArticleContent の中身はおおむね次のようになります。
import { getCloudflareContext } from "@opennextjs/cloudflare";
export async function getArticleContent(meta: ArticleMeta): Promise<string> {
const ctx = getCloudflareContext();
const url = new URL(`/content/articles/${meta.locale}/${meta.category}/${meta.slug}.html`,
"https://placeholder.local");
const res = await ctx.env.ASSETS.fetch(url);
if (!res.ok) throw new Error(`Asset miss: ${url.pathname}`);
return await res.text();
}この設計だと、ASSETS から取れた HTML をそのままサーバーコンポーネントに渡せます。途中で会員判定で分岐しているので、ペイウォール宣言と CSS の組み合わせが崩れない限り、Google にも会員にも一貫したレスポンスを返せます。
実装後の検証は二段構えで
push してから安心するまでに、私は二つの検証を必ず通すことにしています。ひとつは Schema Markup Validator で、有料記事の URL を入れて isAccessibleForFree: "False" と hasPart.cssSelector が正しく拾えるかを確認します。もうひとつは Google Search Console の URL 検査で、レンダリング結果のスクリーンショットに paywall-hidden-content の中身が出ていることと、HTTP ステータスが 200 になっていることを見ます。
GSC で「インデックス登録をリクエスト」までやってしまうのも個人運用なら現実的です。ただ、ペイウォール記事は短期間で評価が伸びるタイプの SEO ではないので、私自身は数週間単位で「クロール済み — インデックス未登録」が減っていくかを静かに観察するようにしています。手応えが出てくると、最初に出していた preview-only の HTML だったときに比べて、長い記事ほど検索結果での平均掲載順位が落ち着いてくるのが分かります。
次に試したい一手
ここまでで「Googlebot に全文 HTML を返しつつ、会員以外には CSS で本文を隠す」「JSON-LD でその境界を明示する」「Cloudflare Workers ASSETS から HTML を取り出す」という三点が揃いました。次に取り組むなら、isAccessibleForFree を読者の状態ごとに動的に書き換える実装でしょうか。記事単体購入を持っているユーザーには "True" を返しても良いはずですが、検索流入の質を見ながら段階的に試したいところです。
個人開発で 4 サイトを並行運用していると、こうした SEO の細部に時間を割きにくくなります。それでも、検索エンジンと読者の両方に対して誠実な構造を保つことが、長くサイトを続けるための地味な土台になるのだと感じています。今日のメモが、有料記事サイトをこれから作る方のひとつの参考になれば嬉しいです。お読みいただき、ありがとうございました。