Shipr uses Clerk’s metadata system to manage billing plans without requiring a separate payment processor integration.
How It Works
- Clerk stores plan metadata - User plans are stored as Clerk session claims
- Plans sync to Convex - The
useSyncUser hook syncs plan data to Convex
- Client-side checks - Use
useUserPlan hook to check plan status
- Server-side enforcement - Access plan data via Clerk auth in API routes
Plans
Two billing plans are available:
- Free - Default plan for all new users
- Pro - Premium features and higher limits
Checking User Plan
Use the useUserPlan hook to check plan status:
~/workspace/source/src/hooks/use-user-plan.ts
import { useAuth } from "@clerk/nextjs";
export type Plan = "free" | "pro";
export function useUserPlan(): {
plan: Plan;
isLoading: boolean;
isPro: boolean;
isFree: boolean;
} {
const { has, isLoaded } = useAuth();
const isPro = isLoaded ? (has?.({ plan: "pro" }) ?? false) : false;
const isFree = !isPro;
const plan: Plan = isPro ? "pro" : "free";
return {
plan,
isLoading: !isLoaded,
isPro,
isFree,
};
}
Usage Example
import { useUserPlan } from "@/hooks/use-user-plan";
function MyComponent() {
const { isPro, isLoading } = useUserPlan();
if (isLoading) return <Skeleton />;
if (isPro) return <ProDashboard />;
return <FreeDashboard />;
}
Show upgrade CTA to free users:
~/workspace/source/src/components/billing/upgrade-button.tsx
import { useUserPlan } from "@/hooks/use-user-plan";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import posthog from "posthog-js";
export function UpgradeButton() {
const { isPro, plan } = useUserPlan();
if (isPro) return null;
const handleUpgradeClick = () => {
posthog.capture("upgrade_button_clicked", {
current_plan: plan,
location: "dashboard",
});
};
return (
<Button
variant="outline"
size="sm"
render={<Link href="/pricing" />}
nativeButton={false}
onClick={handleUpgradeClick}
>
<SparklesIcon />
Upgrade to Pro
</Button>
);
}
Plan Sync to Convex
The useSyncUser hook automatically syncs plan data from Clerk to Convex:
~/workspace/source/src/hooks/use-sync-user.ts
export function useSyncUser() {
const { user, isLoaded } = useUser();
const { has } = useAuth();
const plan =
isLoaded && has ? (has({ plan: "pro" }) ? "pro" : "free") : undefined;
useEffect(() => {
if (!isLoaded || !user) return;
// Only sync if plan changed
if (!existingUser || existingUser.plan !== plan) {
createOrUpdateUser({
clerkId: user.id,
email: user.primaryEmailAddress?.emailAddress ?? "",
name: user.fullName ?? undefined,
imageUrl: user.imageUrl ?? undefined,
plan,
});
}
}, [user, isLoaded, plan, existingUser]);
return { user, convexUser: existingUser, isLoaded };
}
Server-Side Plan Checks
In API routes, check plan via Clerk’s auth() helper:
import { auth } from "@clerk/nextjs/server";
export async function POST(req: Request) {
const { userId, has } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const isPro = has?.({ plan: "pro" }) ?? false;
if (!isPro) {
return NextResponse.json(
{ error: "Pro plan required" },
{ status: 403 }
);
}
// Pro-only logic here
}
Convex Plan Storage
User plans are stored in the Convex users table:
~/workspace/source/convex/schema.ts
users: defineTable({
clerkId: v.string(),
email: v.string(),
name: v.optional(v.string()),
imageUrl: v.optional(v.string()),
plan: v.optional(v.string()), // "free" | "pro"
}).index("by_clerk_id", ["clerkId"])
The plan is synced from Clerk billing metadata via the useSyncUser hook. Do not modify it directly in Convex.
Setting Plan in Clerk
Plans are managed through Clerk’s dashboard or API:
Via Clerk Dashboard
- Go to Users in Clerk dashboard
- Select a user
- Go to Metadata tab
- Add
plan: "pro" to public metadata
Via Clerk API
import { clerkClient } from "@clerk/nextjs/server";
const client = await clerkClient();
await client.users.updateUser(userId, {
publicMetadata: {
plan: "pro",
},
});
Plan-Based Feature Gating
Component-Level Gating
import { useUserPlan } from "@/hooks/use-user-plan";
function ProFeature() {
const { isPro } = useUserPlan();
if (!isPro) {
return <UpgradeCTA feature="Advanced Analytics" />;
}
return <AdvancedAnalytics />;
}
Route-Level Gating
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export default async function ProOnlyPage() {
const { has } = await auth();
const isPro = has?.({ plan: "pro" }) ?? false;
if (!isPro) {
redirect("/pricing");
}
return <ProContent />;
}
API Route Gating
import { auth } from "@clerk/nextjs/server";
export async function POST(req: Request) {
const { has } = await auth();
const isPro = has?.({ plan: "pro" }) ?? false;
if (!isPro) {
return NextResponse.json(
{
error: "This feature requires a Pro plan",
upgrade_url: "/pricing"
},
{ status: 403 }
);
}
// Pro-only logic
}
Plan Change Notifications
Send email when plan changes:
import { sendEmail, planChangedEmail } from "@/lib/emails";
const { subject, html } = planChangedEmail({
name: user.fullName,
previousPlan: "free",
newPlan: "pro",
});
await sendEmail({
to: user.primaryEmailAddress.emailAddress,
subject,
html,
});
Analytics Tracking
Track plan upgrades with PostHog:
import posthog from "posthog-js";
posthog.capture("plan_upgraded", {
previous_plan: "free",
new_plan: "pro",
upgrade_source: "pricing_page",
});
Integrating Payment Processors
To integrate Stripe, Paddle, or other payment processors:
- Create checkout flow - Redirect users to payment processor
- Handle webhooks - Listen for successful payments
- Update Clerk metadata - Set plan via Clerk API on success
- Plan syncs automatically -
useSyncUser updates Convex
Example Stripe Webhook
import { clerkClient } from "@clerk/nextjs/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature")!;
const body = await req.text();
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
const session = event.data.object;
const userId = session.metadata?.clerk_user_id;
if (userId) {
const client = await clerkClient();
await client.users.updateUser(userId, {
publicMetadata: { plan: "pro" },
});
}
}
return new Response(null, { status: 200 });
}
Always validate webhooks using the payment processor’s signature verification to prevent fraud.