If you want to introduce a paywall to your Astro blog - like I do - then Cloudflare Pages, Clerk, and Stripe offer a pretty sweet setup.
In this post I'll show you how you gate some of your content with subscriptions or one-time payments. We'll use Astro for our content, Cloudflare Pages for hosting, Clerk for authentication and Stripe for collecting payments.
Get started with Cloudflare Pages and Clerk
- Deploy Astro to Cloudflare Pages. Cloudflare Pages supports SSR for Astro, which means we can evaluate requests on the server side. Deploy directly from GitHub for continuous integration.
- Create a free Clerk account. Clerk will handle creating and authenticating your users. Their free tier is extremely generous.
-
Use Cloudflare's middleware logic to evaluate requests based on URL, e.g.:
/blog/exclusive-posts/* -
In your
middleware.ts, use Clerk to validate user authentication. Redirect to a paywall when the user is not logged in.
// middleware.ts (simplified)
import { defineMiddleware } from "astro:middleware";
import { createClerkClient } from "@clerk/backend";
export const onRequest = defineMiddleware(async (context, next) => {
const { request, url } = context;
const pathname = url.pathname;
// Only gate the exclusive-posts pages.
if (!pathname.startsWith(EXCLUSIVE_POSTS_PREFIX)) return next();
const secretKey = getClerkSecretKeyFromEnvironment();
const publishableKey = getClerkPublishableKeyFromEnvironment();
const clerkClient = createClerkClient({ secretKey });
// Get auth state
let authState;
try {
authState = await clerkClient.authenticateRequest(request, {
publishableKey,
secretKey,
acceptsToken: "session_token",
});
} catch {
return new Response(null, { status: 302, headers: { Location: paywallRedirectLocation(pathname) } });
}
// Get userId
const userId = getUserIdFromSessionClaims(authState);
if (!userId) {
return new Response(null, { status: 302, headers: { Location: paywallRedirectLocation(pathname) } });
}
// User is logged in, continue
return next();
});
Signing in is only part of the story: for a real paywall you also need to check Clerk user metadata (or equivalent claims) so an active subscription or valid one-time purchase grants access, and redirect everyone else, including logged-in users without entitlement, to your paywall.
At this point you can test this setup manually, without integrating Stripe. Create users manually in the Clerk Dashboard, log in with them and try to access content on your protected URLs. You can even create separate development and production environments in Clerk.
Once you are ready, you can move on to the next phase, which is Stripe integration.
Stripe integration
- Create Stripe Payment Links. Payment Links give you pretty sweet customization options, like mandatory billing address fields and custom fields for newsletter subscriptions, for example. I've created a monthly and a yearly subscription, and one for one-time payments too.
- Create a Cloudflare Worker that will get called by a Stripe webhook. Place this worker within your Astro repo, so you can deploy it together with the site. The task of this worker is to handle Stripe event notifications and react to them: create a new user in Clerk if payment has been processed and no user is found for the given email address, or update user metadata when subscription changes.
- Create a Stripe webhook in the Stripe Dashboard. Provide your Cloudflare Worker URL as the endpoint. Make sure to include the following events:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
Webhook signing and secrets
Stripe signs each webhook request with an HMAC of the raw body and a timestamp, and sends the result in the Stripe-Signature header. Your Worker should read the body as text (not parsed JSON first), verify the signature with your endpoint’s signing secret, and only then JSON.parse the payload. If verification fails, respond with 400 and do not update Clerk.
When you add the endpoint in the Stripe Dashboard, Stripe shows a Signing secret (it starts with whsec_). Treat it like a password: store it only in Cloudflare, not in git.
For a Worker in its own folder (for example workers/stripe-webhook/), define the bindings your code expects (STRIPE_WEBHOOK_SECRET, CLERK_SECRET_KEY) and set the values with Wrangler:
# from the worker directory; use your Worker name / env flags as in wrangler.toml
npx wrangler secret put STRIPE_WEBHOOK_SECRET --config wrangler.toml --env prod
npx wrangler secret put CLERK_SECRET_KEY --config wrangler.toml --env prod
Wrangler prompts you to paste each value; it stores them encrypted on Cloudflare’s side. Repeat with --env dev for a development Worker and use a separate Stripe webhook endpoint (or the Stripe CLI forwarding URL) that points at that URL, with its own signing secret.
In my own setup, I also use three metadata fields on Clerk users that store information about their subscription status.
hasOngoingSubscription: boolean
ongoingAccessUntil: Date
oneTimeAccessUntil: Date
hasOngoingSubscription becomes true when they subscribe and switches to false when they cancel. Upon cancellation the ongoingAccessUntil field gets filled with a date. When someone performs a one-time purchase, their oneTimeAccessUntil field gets filled with a date.
As a bonus, you can create a static Thank You page in your Astro blog. Use confetti.js for a ceremonial feel. Use the URL of this page as the redirect URL in your Stripe Payment Links, so your users land on a nice page after successful payments that gives them instructions about the next steps.
This post has been originally published on my blog.
Top comments (0)