Create Stripe Subscription
Learn how to set up recurring subscription billing with Stripe in Plainform
Learn how to set up recurring subscription billing with Stripe in Plainform.
Goal
By the end of this recipe, you'll have created a subscription product with recurring billing in Stripe.
Prerequisites
- Stripe account set up
- Basic understanding of Stripe products
- Access to Stripe Dashboard
Steps
Create Subscription Product
Navigate to Stripe Dashboard:
https://dashboard.stripe.com/productsClick "Add product" and configure:
Product Details:
- Name: Subscription plan name (e.g., "Pro Monthly", "Enterprise Annual")
- Description: What's included in the subscription
- Image: Optional product image
Pricing:
- Pricing model: Standard pricing
- Price: Monthly or annual amount
- Billing period: Select "Recurring"
- Billing interval: Choose frequency:
- Monthly
- Every 3 months
- Every 6 months
- Yearly
Create separate products for monthly and annual plans to offer both options to customers.
Configure Billing Settings
Trial Period (Optional):
- Set trial days if offering a free trial
- Example: 14 days, 30 days
Usage Type:
- Licensed: Fixed price per billing period (most common)
- Metered: Pay based on usage
Add Marketing Features
Marketing features are displayed as bullet points on your pricing cards. Add them in the Stripe Dashboard:
- Scroll to "Marketing features" section
- Click "Add feature"
- Add feature descriptions for your subscription plan:
Unlimited projects
Priority support
Advanced analytics
Custom integrations
Team collaborationThese features will automatically appear on your pricing page as checkmark bullet points.
Highlight the most valuable features first. Keep each feature concise (3-5 words) and focus on customer benefits.
Test Subscription Flow
Start your development server:
npm run devNavigate to your pricing page and test the subscription:
- Click "Get Started" on your subscription product
- Complete checkout with test card:
4242 4242 4242 4242 - Verify subscription is created in Stripe Dashboard
Use Stripe test mode for development. Test card numbers are available in Stripe's testing documentation.
Subscription Management
Plainform now includes the subscription foundation: authenticated checkout, Stripe customer creation, subscription webhook handlers, local subscription state, and a Stripe Billing Portal route. You still need to add the app-specific logic that decides what each plan unlocks.
Critical: User Database Sync Required
Subscriptions require Clerk users to exist in your database. Plainform includes the Clerk webhook for this, but you must configure it before relying on subscription webhooks.
Required setup:
- Keep the
Usermodel with Clerk user ID as primary key - Configure the Clerk webhook so users sync when they sign up
- Let checkout store
stripeCustomerIdon the local user
Without this setup, subscription webhooks cannot update user records.
👉 Follow the Clerk Webhook Guide to set this up first.
What Plainform Already Handles
- User database sync: Clerk users are synced through the Clerk webhook.
- Authenticated checkout:
/api/stripe/checkoutredirects anonymous users to/sign-in. - Stripe customer linking: Checkout creates or reuses a Stripe customer and stores
metadata.clerkUserId. - Subscription webhook handlers:
customer.subscription.created,customer.subscription.updated, andcustomer.subscription.deletedare handled separately. - Local subscription state:
syncSubscription()writes status, price, period end, cancellation state, andplanKeyto theUserrow. - Subscription management: The user menu posts to
/api/stripe/portalso customers can manage billing in Stripe Billing Portal.
What You Add For Your App
- Plan metadata: Add fields like
planKey,maxProjects,maxSeats, orcreditsto Stripe product/price metadata. - Entitlement persistence: Extend
syncSubscription()to store those limits on your local user. - Access control: Check subscription status and plan limits before showing paid features.
- Feature gating: Enforce limits in server actions, API routes, and protected pages.
- Side effects: Add emails, audit logs, onboarding records, or cleanup logic in the matching event handler.
Example Implementation
Below is an example of how to handle subscriptions. Adapt it to your needs.
Database Schema
Add subscription fields to track user access:
model User {
id String @id
email String @unique
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
subscriptionStatus String? // 'active', 'canceled', 'past_due'
subscriptionCurrentPeriodEnd DateTime? // When subscription expires
cancelAtPeriodEnd Boolean @default(false)
planKey String? // From Stripe product metadata or price lookup key
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add your own feature limit fields based on your app
// Examples:
// maxProjects String? @default("0")
// isUnlimitedProjects Boolean @default(false)
// maxApiCalls Int? @default(1000)
// maxTeamMembers Int? @default(1)
}Key fields:
subscriptionStatus: Current subscription statesubscriptionCurrentPeriodEnd: When access expires (user keeps access until this date)cancelAtPeriodEnd: Whether the subscription is scheduled to cancelplanKey: Stable app-facing plan identifier, usually from Stripe product metadata
Feature limit fields (customize for your app):
- Add fields like
maxProjects,maxApiCalls, etc. based on what limits you need - Store these in Stripe product metadata and sync them via webhooks
Where To Add App Logic
Plainform keeps one handler per subscription event:
app/api/webhooks/stripe/route.ts
├─ customer.subscription.created → handleSubscriptionCreated()
├─ customer.subscription.updated → handleSubscriptionUpdated()
└─ customer.subscription.deleted → handleSubscriptionDeleted()Use these locations intentionally:
syncSubscription()for durable fields used by access control.handleSubscriptionCreated()for created-only effects like onboarding emails.handleSubscriptionUpdated()for upgrade, downgrade, renewal, or cancellation-scheduled effects.handleSubscriptionDeleted()for cleanup after access ends.
In the subscription handlers, add your app logic after syncSubscription()
or clearSubscription() succeeds. That keeps the local billing state correct
before you send emails, create onboarding records, reset usage, or change
feature access.
Example extension in syncSubscription():
const price = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
const product = price.product as Stripe.Product;
const planKey = product.metadata?.planKey ?? price.lookup_key ?? null;
const maxProjects = product.metadata?.maxProjects ?? '0';
await prisma.user.upsert({
where: { id: clerkUserId },
update: {
subscriptionStatus: subscription.status,
subscriptionCurrentPeriodEnd: getSubscriptionPeriodEnd(subscription),
cancelAtPeriodEnd: isCanceling(subscription),
planKey,
// maxProjects,
},
create: {
id: clerkUserId,
email,
subscriptionStatus: subscription.status,
subscriptionCurrentPeriodEnd: getSubscriptionPeriodEnd(subscription),
cancelAtPeriodEnd: isCanceling(subscription),
planKey,
// maxProjects,
},
});Example created-only side effect:
export async function handleSubscriptionCreated(
subscription: Stripe.Subscription
) {
const res = await syncSubscription(subscription);
if (!res.ok) {
return res;
}
// Add your app logic after the subscription is synced:
// await sendWelcomeEmail(subscription);
// await createWorkspaceDefaults(subscription);
// await recordBillingEvent(subscription, 'created');
return res;
}Example update-only side effect:
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
const res = await syncSubscription(subscription);
if (!res.ok) {
return res;
}
// Add your app logic after the latest plan/status is synced:
// await notifyPlanChanged(subscription);
// await resetMonthlyUsageIfNeeded(subscription);
// await sendCancellationScheduledEmail(subscription);
return res;
}Example deleted-only side effect:
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
) {
const res = await clearSubscription(subscription);
if (!res.ok) {
return res;
}
// Add your app logic after subscription fields are cleared:
// await downgradeWorkspace(subscription);
// await archivePaidResources(subscription);
// await sendCancellationEmail(subscription);
return res;
}Access Control
Check subscription status before granting access:
export async function checkSubscriptionAccess(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
subscriptionCurrentPeriodEnd: true,
},
});
if (!user) return false;
// Check if subscription is active
if (user.subscriptionStatus !== 'active') return false;
// Check if subscription hasn't expired
if (user.subscriptionCurrentPeriodEnd) {
const now = new Date();
if (now > user.subscriptionCurrentPeriodEnd) return false;
}
return true;
}Use in your app:
export default async function DashboardPage() {
const { userId } = await auth();
const hasAccess = await checkSubscriptionAccess(userId);
if (!hasAccess) {
redirect('/pricing');
}
return <Dashboard />;
}Webhook Configuration
Add these events to your Stripe webhook:
- Go to Stripe Dashboard → Developers → Webhooks
- Add endpoint:
https://yourdomain.com/api/webhooks/stripe - Select events:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deleted
For complete webhook setup and data flow, see Stripe Webhook.
Important Notes
- User keeps access until
currentPeriodEndeven if subscription is canceled - Store product metadata (like
maxProjects) in Stripe product settings - Handle edge cases: payment failures, downgrades, upgrades
- Test thoroughly with Stripe test mode before going live
Common Issues
Subscription Not Creating
- Verify webhook endpoint is configured in Stripe Dashboard
- Check that webhook secret is set in
.env - Ensure the product has an active recurring price
- Check browser console and server logs for errors
Customer Not Charged
- Confirm the subscription is active in Stripe Dashboard
- Verify payment method is valid
- Check for failed payment events in Stripe Dashboard
- Review webhook logs for payment failures
Trial Not Working
- Ensure trial period is set on the price in Stripe Dashboard
- Verify the subscription was created with trial settings
- Check that the trial end date is correct in Stripe
Best Practices
Pricing Strategy:
- Offer both monthly and annual options
- Provide 15-20% discount for annual plans
- Consider a free trial to reduce friction
Communication:
- Send email confirmations for new subscriptions
- Notify users before trial ends
- Alert customers about failed payments
Cancellation:
- Make cancellation easy to build trust
- Offer pause or downgrade options
- Collect feedback on why users cancel
Next Steps
- Add Coupon - Create discount codes for subscriptions
- Test Payments Locally - Test subscription webhooks
- Customize Checkout - Customize the checkout experience
How is this guide ?
Last updated on