This article was co-written with generative AI. Where possible the facts have been cross-checked against official documentation, but errors may remain. Please verify with primary sources before acting on anything important here.
Overview
Migrating tech.ldas.jp from Hugo to Next.js on Cloudflare Workers, I added Google sign-in, a Stripe-backed annual membership, and a soft gate that lets members-only posts show a preview to non-members.
This particular combination — Workers + Next.js (App Router) + better-auth + Stripe — has very few end-to-end implementation writeups, in either Japanese or English. Workers + Stripe examples in the wild tend to use Hono, and most Stripe + Next.js tutorials assume Vercel hosting. better-auth itself only hit 1.0 in late 2024 and OpenNext for Cloudflare matured during 2025, so the whole stack only became practical recently.
These notes focus on the parts that bit me, not the happy path you can already find in the docs.
Architecture
┌─ Cloudflare Workers (tech.ldas.jp) ──────────────────────────┐
│ │
│ Next.js 15 (App Router) via @opennextjs/cloudflare │
│ ├ Pages: posts/tags/categories/favorites/membership │
│ ├ /api/auth/[...all] ─→ better-auth │
│ ├ /api/stripe/checkout ─→ Stripe Checkout Session │
│ ├ /api/stripe/portal ─→ Customer Portal session │
│ └ /api/stripe/webhook ─→ syncs subscription state to D1 │
│ │
│ Bindings: │
│ ├ DB : D1 (better-auth tables + favorite + sub.) │
│ └ ASSETS : Workers Static Assets │
│ │
└──────────────────────────────────────────────────────────────┘
│
├──→ Google OAuth (sign-in only, scope: email + profile)
└──→ Stripe (product, price, Checkout, Webhook)
| Layer | Choice | Notes |
|---|---|---|
| Hosting | Cloudflare Workers Static Assets (via OpenNext) | Free plan, no R2/KV |
| Framework | Next.js 15 App Router | Mostly SSG, dynamic only on auth-aware pages |
| Auth | better-auth v1.x | Google OAuth only; email/password disabled |
| DB | Cloudflare D1 (SQLite) | better-auth tables + favorite + subscription |
| Payments | Stripe (test → live) | Checkout Session + Customer Portal + Webhook |
| ORM | Drizzle | Plays well with better-auth |
| Styling | Tailwind v4 | Ported from the previous Hugo theme |
Almost everything can be done from the CLI
The only Stripe Dashboard interaction was the live-mode activation form. Product creation, price setup, webhook registration, and Worker secrets injection all run through curl and wrangler.
SK="sk_test_..."
# 1. Create the product
PROD=$(curl -sS https://api.stripe.com/v1/products \
-u "$SK:" \
-d "name=tech.ldas.jp Membership" \
-d "description=Access to members-only posts")
PROD_ID=$(echo "$PROD" | jq -r .id)
# 2. Annual ¥5,000 recurring price
PRICE=$(curl -sS https://api.stripe.com/v1/prices \
-u "$SK:" \
-d "product=$PROD_ID" \
-d "currency=jpy" \
-d "unit_amount=5000" \
-d "recurring[interval]=year")
PRICE_ID=$(echo "$PRICE" | jq -r .id)
# 3. Webhook (the signing secret only appears in this response)
WH=$(curl -sS https://api.stripe.com/v1/webhook_endpoints \
-u "$SK:" \
-d "url=https://tech.ldas.jp/api/stripe/webhook" \
-d "enabled_events[]=checkout.session.completed" \
-d "enabled_events[]=customer.subscription.created" \
-d "enabled_events[]=customer.subscription.updated" \
-d "enabled_events[]=customer.subscription.deleted")
WH_SECRET=$(echo "$WH" | jq -r .secret)
# 4. Push to Worker secrets
printf '%s' "$PRICE_ID" | npx wrangler secret put STRIPE_PRICE_ID
printf '%s' "$WH_SECRET" | npx wrangler secret put STRIPE_WEBHOOK_SECRET
The big benefit of doing it this way is reproducibility. Switching to live mode just means re-running the same script with sk_live_* instead of sk_test_*.
Pitfall 1: Workers Free plan 10 ms CPU limit
Workers Free plan caps CPU at 10 ms per request. That sounds tight but is more brutal than expected — a naive implementation hit it constantly.
Symptom: Posts metadata stored as a single posts-{lang}.json (≈ 9 MB) under /public/_data/, parsed on every request, made cold workers blow past 10 ms and return 503 / 1102 errors.
Fix: Bundle the posts index directly into the Worker bundle.
// scripts/build-posts-bundle.mjs (excerpt)
const tsContent = [
"// AUTO-GENERATED — DO NOT EDIT",
"/* eslint-disable */",
"// @ts-nocheck",
"",
`export const POSTS_INDEX_JA = ${JSON.stringify(indexBundle.ja)};`,
`export const POSTS_INDEX_EN = ${JSON.stringify(indexBundle.en)};`,
...
].join("\n");
await fs.writeFile("src/generated/posts-index.ts", tsContent);
// src/lib/posts.ts
import { POSTS_INDEX_JA, POSTS_INDEX_EN } from "@/generated/posts-index";
export async function getAllPostMeta(lang: Lang): Promise<PostMeta[]> {
const data = lang === "ja" ? POSTS_INDEX_JA : POSTS_INDEX_EN;
return (data as PostMeta[]).map((p) => ({ ...p, slug: String(p.slug), lang }));
}
JSON.parse cost goes away; the data lives in memory after the first JS evaluation. Bundle size grows by under 1 MB raw — gzipped that's roughly 200 KB, well within the Free plan compressed limit.
Article bodies are larger so I kept those as per-post JSON files at public/_data/posts/{lang}/{slug}.json, fetched via env.ASSETS.fetch only when an article is actually requested.
The other contributor to CPU pressure was Next.js prefetch on hover — visiting the home page would fire a dozen background RSC fetches simultaneously, easily exhausting cold-worker budgets. I wrapped next/link and disabled prefetching globally:
// src/components/Link.tsx
import NextLink from "next/link";
export default function Link({ prefetch = false, ...rest }) {
return <NextLink prefetch={prefetch} {...rest} />;
}
Pitfall 2: Stripe SDK on Workers
The Stripe Node SDK reaches for Node's http module, which doesn't exist on Workers. Pass an explicit fetch-based HTTP client:
// src/lib/stripe.ts
import Stripe from "stripe";
export function getStripe(env: { STRIPE_SECRET_KEY: string }): Stripe {
return new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: "2026-04-22.dahlia",
httpClient: Stripe.createFetchHttpClient(),
});
}
Webhook signature verification also needs the async variant — the synchronous constructEvent uses Node crypto and fails on Workers' WebCrypto:
const event = await stripe.webhooks.constructEventAsync(
await request.text(),
signature,
env.STRIPE_WEBHOOK_SECRET,
);
A subtler change: in 2025-era Stripe API versions, subscription.current_period_end moved from the top level into individual items.data[*].current_period_end. The SDK types reflect this, so older code that reads sub.current_period_end no longer compiles. I cast through unknown to handle both shapes:
const subAny = sub as unknown as {
current_period_end?: number;
items?: { data?: { current_period_end?: number }[] };
};
const endSec =
subAny.current_period_end ??
subAny.items?.data?.[0]?.current_period_end ??
0;
Pitfall 3: SSG vs members_only gating
Members-only post gating reads the session via headers() at request time. If the route is statically generated, Next.js complains: Page changed from static to dynamic at runtime, reason: headers.
The simplest fix was to mark the article route as force-dynamic outright:
// src/app/[lang]/posts/[slug]/page.tsx
export const dynamic = "force-dynamic";
Because the posts index is bundled into memory, fetching one post via env.ASSETS.fetch is cheap (a single small JSON read). End-to-end response time stays well under the CPU budget.
The gating logic is short:
let signedIn = false, isMember = false;
if (post.members_only) {
const { env } = await getCloudflareContext();
const auth = getAuth(env);
const session = await auth.api.getSession({ headers: await headers() });
signedIn = !!session?.user;
if (signedIn) isMember = await isActiveMember(env, session!.user.id);
}
const locked = !!post.members_only && !isMember;
const bodyHtml = locked
? truncateHtmlForPreview(post.contentHtml, { lang }).html
: post.contentHtml;
isActiveMember is a thin helper that reads the subscription table and returns true when status is active or trialing.
Pitfall 4: domain handover from Pages to Workers
Cutting tech.ldas.jp over from Cloudflare Pages to a Worker fails as long as the old CNAME still points at nakamura196.pages.dev:
Hostname 'tech.ldas.jp' already has externally managed DNS records.
Pages Custom Domains and Workers Custom Domains can't share a hostname. The old binding has to come off first:
TOKEN=$(grep oauth_token ~/Library/Preferences/.wrangler/config/default.toml \
| cut -d'"' -f2)
ACCOUNT="..."
curl -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/pages/projects/<proj>/domains/tech.ldas.jp" \
-H "Authorization: Bearer $TOKEN"
Even after that, the underlying CNAME record itself stays in DNS. Wrangler's OAuth token doesn't carry the zone:edit scope, so the actual record deletion has to be done from the Cloudflare dashboard. This is the only step in the whole pipeline that didn't fit cleanly into a script.
Once the CNAME is gone, wrangler deploy succeeds and Cloudflare auto-provisions a fresh DNS record and SSL certificate for the Worker.
Schema notes for better-auth + Stripe
I used userId as the primary key on the subscription table — that enforces "one user, one subscription" at the DB level:
// src/db/schema.ts (excerpt)
export const subscription = sqliteTable("subscription", {
userId: text("user_id").primaryKey().references(() => user.id),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
status: text("status", { enum: ["active", "trialing", "canceled", ...] }),
currentPeriodEnd: integer("current_period_end", { mode: "timestamp_ms" }),
});
Setting account.accountLinking.enabled = true in better-auth means a user who originally signed up with email + password and later signs in with Google (same email) gets recognized as the same user — and any existing subscription transfers cleanly.
Minimizing PII
I added databaseHooks to better-auth so that access/refresh/id tokens are nulled out before any insert into the account table. Since the site never re-calls Google APIs after sign-in, those tokens have no use beyond enlarging the blast radius of a database leak.
databaseHooks: {
account: {
create: {
before: async (data) => ({
data: {
...data,
accessToken: null,
refreshToken: null,
idToken: null,
},
}),
},
},
session: {
create: {
before: async (data) => ({
data: { ...data, userAgent: null, ipAddress: null },
}),
},
},
},
advanced: {
ipAddress: { ipAddressHeaders: [] },
},
PII minimization is also easier to declare honestly in a privacy policy when you've actually done it at the storage layer.
Japan-specific business onboarding notes
A few things I'd have liked to know before walking through the Stripe live-mode activation form:
- Existing GitHub Sponsors / Buy Me a Coffee Stripe Connect accounts show up as choices, but they're managed by those upstream services and can't be used to sell your own products. Pick "create a new account."
- Business category: For a tech blog selling memberships, "Blogs and articles" fits the actual product better than "Software" — less risk of Stripe questioning the discrepancy during review.
- Security checklist (割賦販売法): Japan requires every online card-accepting business to file a security checklist from the credit-card industry council. Almost every item on it can be checked off as "handled by Stripe" or "handled by Cloudflare," which is a relief.
- Statement descriptor has three fields (Kanji / Katakana / ASCII) of up to 22 characters each. The Kanji field accepts Katakana too if your service has no native Kanji name.
- Personal phone number on receipts: leave this off. Stripe still keeps your number for identity verification, but it doesn't need to appear on customer-facing receipts. For digital-content businesses, the chargeback-reduction benefit is small and the privacy risk is real.
- Stripe Tax and Climate can both be skipped initially. Stripe Tax adds a 0.5% surcharge per transaction; below the consumption-tax threshold (¥10M annual revenue) Japanese individual operators don't need to collect tax anyway.
Pricing strategy
The plan started as ¥500/month and shifted to a single annual ¥5,000 plan. Reasons:
- A yearly cycle removes the monthly "should I cancel?" friction
- Renewal events cluster on one date per customer, simplifying ops
- The DH/research audience is comfortable with annual subscriptions (similar to journals or association dues)
- ¥5,000/year vs ¥6,000/year-equivalent ≈ a 17% discount on a yearly commitment
Benefits are intentionally narrow:
✓ Continued support for site operations and writing (primary)
✓ Access to members-only posts
I deliberately removed "monthly production notes" from the original plan. Promising regular publication on top of normal blog writing is a recipe for failure on a one-person operation. "Sometimes I write members-only posts, sometimes I don't" is more honest and more sustainable.
What's not done yet
- R2 incremental cache: still on the in-memory adapter. Cold workers re-render SSG pages, which is fine at current traffic but R2 would be the proper fix.
- Specified Commercial Transactions Act page (Japanese consumer law disclosure): needed if recurring sales continue.
- Additional pricing tiers: a student tier (¥2,500/year) and a "supporter" tier (¥10,000/year) are both easy to add later — Stripe just needs another
Priceobject.
Wrap-up
Cloudflare Workers + better-auth + Stripe isn't the well-trodden path for a Next.js membership site, and that means the initial debugging cost is real. In return:
- Monthly hosting cost is essentially zero (Workers Free + D1 free + Stripe transaction fees)
- Vendor lock-in is minimal (everything except Stripe is open source or has compatible alternatives)
- Almost the entire Next.js feature surface works (App Router / Server Components / Streaming)
This stack fits a "I'd rather solve the problems myself than pay $20/month to Vercel" temperament. If you'd rather follow an official tutorial that just works, Vercel + Auth.js + Stripe is the less exhausting choice.
This site itself runs on the stack described here, so the notes above come from an active production deployment rather than an abstract plan.



