CLAUDE LABJP
FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as OctoberFORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Back to Blog

Adding Google Flexible Sampling to a Next.js + Stripe Paywalled Article Site — Implementation Notes

nextjsstripecloudflare-workersseoindie-developerpaywall

I have been building apps on my own since 2014. Somewhere around 50 million cumulative downloads, I started wanting to treat the documentation and operational notes around those apps as their own quiet body of work. Running membership articles across the four Lab sites I publish — Claude Lab is one of them — surfaced one stubborn question early on: how do you tell search engines, with no ambiguity, where the paywall begins? These are my notes from wiring Google Flexible Sampling into a Next.js App Router site whose checkout flow is on Stripe and whose runtime is Cloudflare Workers.

Why Flexible Sampling became the right answer

The first version was the obvious one. Check the membership cookie on the server, return only the intro HTML to non-members, and hide everything after the paywall. It felt safe at first, but two slow problems showed up.

The first was that organic traffic plateaued. No matter how carefully I wrote a long article, only the preview HTML reached Googlebot, so longer paywalled pieces never earned the ranking I expected. The second was more uncomfortable: serving meaningfully different content based on a cookie can drift, depending on framing, into territory that Google treats as cloaking. Without an explicit declaration that says "everything below this line is paid," the site sits in a grey zone that can erode trust over time.

The fix is documented in the Google Flexible Sampling guidance: use Schema.org's hasPart with a cssSelector to label the paid region, and then return the full HTML to everyone while hiding the paid region with CSS for non-members. The result is no ambiguity for Googlebot, and the entire article body becomes eligible for ranking.

What hasPart is actually telling Google

The core of Flexible Sampling is the JSON-LD that travels with each article. I emit something like this from the server component.

const articleJsonLd = {
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": article.meta.title,
  "datePublished": article.meta.date,
  "author": { "@type": "Person", "name": "Masaki Hirokawa" },
  "isAccessibleForFree": article.meta.premium ? "False" : "True",
  ...(article.meta.premium && {
    hasPart: {
      "@type": "WebPageElement",
      "isAccessibleForFree": "False",
      "cssSelector": ".paywall-hidden-content",
    },
  }),
};

Three details matter. isAccessibleForFree must be the string "False" (not the boolean) for paid articles. hasPart.cssSelector must name a single class that wraps the entire paid region. And the HTML on the page must actually use that class. With those three pieces in place, Googlebot reads the selector and knows that anything inside it is paid territory; it is no longer surprised that anonymous visitors do not see it.

I deliberately picked .paywall-hidden-content as the class name because it is unambiguous in the source. If I ever rename my BEM-like conventions, this is the one spot I will need to keep stable.

A preview + paywall layout in the App Router

The server component splits the full HTML into a preview half and a paywalled half, then renders both for non-members. I trimmed the snippet to the parts worth showing.

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>
);

What this gives me is exactly the contract Flexible Sampling expects. The full HTML is always present in the server response, so Googlebot sees everything. Non-members receive the preview plus a hidden paywall region wrapped in the declared selector. The cut point is calculated from the count of H2 headings, which keeps the free portion proportional whether the article has four headings or twelve.

Hiding the paid region without breaking the contract

How you hide the paywall region matters more than it looks. opacity: 0 leaves the text selectable, which feels uncanny. visibility: hidden keeps the layout footprint, which leaves an awkward gap. The version I landed on is just one line.

.paywall-hidden-content {
  display: none;
}

In most SEO contexts display: none is something you avoid because Google may discount the content. Inside a properly declared hasPart region, it is exactly the right tool: Google already knows the area is paid, and the CSS rule simply enforces the visual side of the same agreement.

A Cloudflare Workers caveat worth remembering

The site runs Next.js 16 on Cloudflare Workers. Article HTML is split out into public/content/articles/{locale}/{category}/{slug}.html so the Worker bundle stays under the 62 MiB traversal limit, and I fetch it through the ASSETS binding. The thing that bit me on day one is that Workers do not allow fetch() against your own hostname. Every read has to go through getCloudflareContext().env.ASSETS.fetch().

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();
}

With this layout, the server component hands the HTML straight into the JSX, and the paywall declaration and CSS keep behaving consistently whether the user is a member or not.

Verifying the implementation, twice

I never trust a paywall deploy until two checks pass. The first is the Schema Markup Validator, which I run against a paid URL to confirm that both isAccessibleForFree: "False" and the hasPart.cssSelector come through cleanly. The second is the URL Inspection tool in Google Search Console, where I want to see two things in the rendered screenshot: HTML status 200 and the paid HTML actually present in the rendered DOM, even though it is visually hidden.

Requesting indexing for the URL is reasonable for a solo developer. The honest read on results comes weeks later, though, when I watch whether "Crawled — currently not indexed" drifts down for the longer paid articles. When the setup is working, longer pieces start settling into a steadier average position than they did when only the preview reached Googlebot.

One more change I am thinking about

The current setup covers three of the moving parts: returning full HTML to Googlebot, hiding the paid region in CSS for non-members, and labeling that region with structured data. The next experiment I want to run is to flip isAccessibleForFree per visitor when the user has bought a single-article unlock — they should arguably see "True" for that one slug. I want to read the search performance for a few weeks before changing that lever, though.

Running four sites on my own means SEO details like these are the easiest things to defer. The discipline I keep coming back to is that a structure honest to both search engines and readers is the quiet foundation that lets a site keep going. If even one of these notes saves another indie developer an evening of debugging, that feels like a fair trade for writing it down. Thank you for reading.