Billing is one of those features that sounds simple until you're three hours in and debugging why a user who just paid is still seeing the free-tier UI. I integrated Clerk Billing into MediSync — a healthcare SaaS admin platform — over a weekend. Here's the real implementation, including the parts that took way longer than they should have.
Why Clerk Billing vs. Raw Stripe
The alternative is integrating Stripe directly: create a customers table, sync it with Stripe customer IDs, write webhooks for every subscription event, implement your own plan-gating logic, and manage the session refresh lifecycle yourself.
Clerk Billing handles the Stripe side entirely and exposes plan metadata directly on the Clerk session — which means your plan information is available instantly in every Server Component and Server Action without a database lookup.
// Available server-side with zero database call
const { has } = await auth();
const isOnProPlan = has({ plan: "pro" });
If you're already using Clerk for auth, this is the right choice. The tradeoff is less raw Stripe flexibility — but for 90% of SaaS billing requirements, you don't need it.
Setting Up Plans in the Clerk Dashboard
Plans are configured in the Clerk dashboard under Billing → Plans. I created three:
- Starter (free) — limited to 10 doctor profiles, no payout approvals
- Growth ($29/mo) — up to 100 doctors, payout approvals, audit log export
- Enterprise ($99/mo) — unlimited doctors, custom roles, priority support
Each plan has a Stripe Price ID that Clerk attaches automatically when you configure the Stripe integration. You don't manage this mapping yourself.
The first gotcha: The plan feature flags you define in the Clerk dashboard need to match exactly what you check in code with has(). I named one feature payouts:approve in the dashboard and checked payout:approve in code. Silent mismatch — the check always returned false. Spend 10 minutes naming features carefully before you write a single line of code.
Gating Features in Server Components
// app/admin/payouts/page.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export default async function PayoutsPage() {
const { has } = await auth();
if (!has({ permission: "payouts:approve" })) {
redirect("/admin/upgrade");
}
return <PayoutsTable />;
}
For UI-level gating (show the feature but disable it with an upgrade prompt):
// components/PayoutApproveButton.tsx
import { auth } from "@clerk/nextjs/server";
export async function PayoutApproveButton({ payoutId }: { payoutId: string }) {
const { has } = await auth();
const canApprove = has({ permission: "payouts:approve" });
if (!canApprove) {
return (
<UpgradePrompt
feature="Payout Approvals"
requiredPlan="Growth"
href="/admin/upgrade"
/>
);
}
return <ApprovePayoutForm payoutId={payoutId} />;
}
This is an async Server Component — the auth() call runs on the server, the result is serialised into the initial HTML, and no client-side fetch is needed.
The Checkout Flow
Clerk Billing provides its own pre-built checkout UI. Triggering it from a Next.js client component:
"use client";
import { useClerk } from "@clerk/nextjs";
export function UpgradeButton({ planId }: { planId: string }) {
const { openCheckout } = useClerk();
return (
<button
onClick={() => openCheckout({ planId })}
className="..."
>
Upgrade to Growth
</button>
);
}
The openCheckout method opens a Clerk-hosted Stripe Checkout session. After payment, Clerk updates the subscription, refreshes the session token, and the user's has() checks immediately reflect the new plan.
The second gotcha: The session refresh after payment isn't instant. There's a brief window (a few hundred milliseconds to a few seconds depending on Stripe webhook propagation) where the user has paid but their session still reflects the old plan. I added an intermediate "Payment successful — setting up your account..." state with a useUser polling hook that re-renders when the session updates:
"use client";
import { useUser } from "@clerk/nextjs";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function PaymentSuccessRedirect({ targetPlan }: { targetPlan: string }) {
const { user, isLoaded } = useUser();
const router = useRouter();
useEffect(() => {
if (!isLoaded) return;
// Check if the session now reflects the upgraded plan
const hasPlan = user?.organizationMemberships.some(
m => m.publicMetadata.plan === targetPlan
);
if (hasPlan) {
router.push("/admin/dashboard");
}
}, [user, isLoaded, targetPlan, router]);
return <LoadingSpinner message="Activating your plan..." />;
}
Webhooks: Don't Skip These
Clerk sends webhooks for every billing event. Even though the session-based gating handles most real-time needs, you need webhooks for:
- Provisioning side effects on plan upgrade (e.g., creating default records, sending a welcome email)
- Cleanup on plan downgrade or cancellation (e.g., soft-deleting data above the tier limit)
- Your own billing analytics (events persisted to your database for reporting)
// app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { headers } from "next/headers";
import { WebhookEvent } from "@clerk/nextjs/server";
export async function POST(req: Request) {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
const headerPayload = await headers();
const svixId = headerPayload.get("svix-id");
const svixTimestamp = headerPayload.get("svix-timestamp");
const svixSignature = headerPayload.get("svix-signature");
const body = await req.text();
const wh = new Webhook(WEBHOOK_SECRET!);
let event: WebhookEvent;
try {
event = wh.verify(body, {
"svix-id": svixId!,
"svix-timestamp": svixTimestamp!,
"svix-signature": svixSignature!,
}) as WebhookEvent;
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Handle billing events
if (event.type === "organization.updated") {
const { id, public_metadata } = event.data;
if (public_metadata?.plan) {
await handlePlanChange(id, public_metadata.plan as string);
}
}
return new Response("OK", { status: 200 });
}
The third gotcha: Svix (the webhook delivery service Clerk uses) retries failed webhooks. If your handler is slow or throws an error, you'll receive the same webhook multiple times. Make your handlers idempotent — processing the same event twice should produce the same result as processing it once. I use database upserts rather than inserts:
async function handlePlanChange(orgId: string, newPlan: string) {
await db.organization.upsert({
where: { clerkId: orgId },
create: { clerkId: orgId, plan: newPlan },
update: { plan: newPlan, planUpdatedAt: new Date() },
});
}
The Downgrade Edge Case
When a user downgrades from Growth to Starter, they may have 75 doctor profiles but the Starter plan limits them to 10. You have two choices:
- Hard limit — block access to profiles 11–75 immediately. Feels punitive and loses data.
- Soft limit — they can view existing profiles but can't add new ones until they're below the limit. Feels fair, preserves data.
I implemented the soft limit. The check on the "Add Doctor" action:
async function addDoctor(data: DoctorFormData) {
const { has, orgId } = await auth();
const currentCount = await db.doctor.count({ where: { orgId } });
const limit = has({ plan: "growth" }) ? 100 : 10;
if (currentCount >= limit) {
throw new Error(
`Plan limit reached. Your current plan allows up to ${limit} doctors.`
);
}
// ... create doctor
}
What I'd Do Differently
Set up Clerk Billing before any feature gating. I added it mid-project and had to retrofit the has() checks throughout. If billing is a planned feature, integrate it on day one — even if you're initially putting everyone on the free plan.
Webhook monitoring from day one. Use Clerk's webhook delivery dashboard and set up a dead-letter alerting system. Missed billing events are silent and financially consequential.
Test the downgrade path manually. Happy-path testing (upgrade, gain features) is easy. The downgrade path (lose features, enforce limits) has more edge cases and breaks more often.
The Summary
Clerk Billing is genuinely good. The session-based plan metadata means zero database calls for gating decisions, the checkout flow is production-ready out of the box, and the webhook system is reliable. The complexity is manageable once you understand the session refresh timing, idempotent webhook handlers, and the downgrade edge cases.
Billing isn't glamorous, but it's the feature that makes everything else matter. Get it right.