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/products

Click "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:

  1. Scroll to "Marketing features" section
  2. Click "Add feature"
  3. Add feature descriptions for your subscription plan:
Unlimited projects
Priority support
Advanced analytics
Custom integrations
Team collaboration

These 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 dev

Navigate to your pricing page and test the subscription:

  1. Click "Get Started" on your subscription product
  2. Complete checkout with test card: 4242 4242 4242 4242
  3. 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:

  1. Keep the User model with Clerk user ID as primary key
  2. Configure the Clerk webhook so users sync when they sign up
  3. Let checkout store stripeCustomerId on 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

  1. User database sync: Clerk users are synced through the Clerk webhook.
  2. Authenticated checkout: /api/stripe/checkout redirects anonymous users to /sign-in.
  3. Stripe customer linking: Checkout creates or reuses a Stripe customer and stores metadata.clerkUserId.
  4. Subscription webhook handlers: customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted are handled separately.
  5. Local subscription state: syncSubscription() writes status, price, period end, cancellation state, and planKey to the User row.
  6. Subscription management: The user menu posts to /api/stripe/portal so customers can manage billing in Stripe Billing Portal.

What You Add For Your App

  1. Plan metadata: Add fields like planKey, maxProjects, maxSeats, or credits to Stripe product/price metadata.
  2. Entitlement persistence: Extend syncSubscription() to store those limits on your local user.
  3. Access control: Check subscription status and plan limits before showing paid features.
  4. Feature gating: Enforce limits in server actions, API routes, and protected pages.
  5. 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:

prisma/schema.prisma
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 state
  • subscriptionCurrentPeriodEnd: When access expires (user keeps access until this date)
  • cancelAtPeriodEnd: Whether the subscription is scheduled to cancel
  • planKey: 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():

lib/stripe/webhooks/syncSubscription.ts
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:

lib/stripe/webhooks/handleSubscriptionCreated.ts
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:

lib/stripe/webhooks/handleSubscriptionUpdated.ts
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:

lib/stripe/webhooks/handleSubscriptionDeleted.ts
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:

lib/subscription/checkAccess.ts
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:

app/dashboard/page.tsx
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:

  1. Go to Stripe Dashboard → DevelopersWebhooks
  2. Add endpoint: https://yourdomain.com/api/webhooks/stripe
  3. Select events:
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted

For complete webhook setup and data flow, see Stripe Webhook.

Important Notes

  • User keeps access until currentPeriodEnd even 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

How is this guide ?

Last updated on

On this page