React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripesubscriptionsrecurringpricingtrialsSaaS

Subscriptions

Recipe

Create subscription products and prices in Stripe, then use Checkout Sessions or PaymentIntents to subscribe customers. Manage subscription lifecycle with the Customer Portal and webhooks.

Create a subscription via Checkout Session:

// app/actions/subscribe.ts
"use server";
 
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
 
export async function createSubscriptionCheckout(priceId: string) {
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    subscription_data: {
      trial_period_days: 14,
    },
  });
 
  redirect(session.url!);
}

Or create a subscription directly via the API:

// app/actions/subscribe-direct.ts
"use server";
 
import { stripe } from "@/lib/stripe";
 
export async function createSubscription(
  customerId: string,
  priceId: string
) {
  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_behavior: "default_incomplete",
    payment_settings: {
      save_default_payment_method: "on_subscription",
    },
    expand: ["latest_invoice.payment_intent"],
  });
 
  const invoice = subscription.latest_invoice as Stripe.Invoice;
  const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;
 
  return {
    subscriptionId: subscription.id,
    clientSecret: paymentIntent.client_secret!,
  };
}

Working Example

// app/pricing/page.tsx
import { stripe } from "@/lib/stripe";
import { createSubscriptionCheckout } from "@/app/actions/subscribe";
 
interface PricingTier {
  name: string;
  priceId: string;
  price: number;
  interval: string;
  features: string[];
  popular?: boolean;
}
 
const tiers: PricingTier[] = [
  {
    name: "Starter",
    priceId: "price_starter_monthly",
    price: 9,
    interval: "month",
    features: ["5 projects", "1 GB storage", "Email support"],
  },
  {
    name: "Pro",
    priceId: "price_pro_monthly",
    price: 29,
    interval: "month",
    features: ["Unlimited projects", "10 GB storage", "Priority support", "API access"],
    popular: true,
  },
  {
    name: "Enterprise",
    priceId: "price_enterprise_monthly",
    price: 99,
    interval: "month",
    features: [
      "Unlimited everything",
      "100 GB storage",
      "Dedicated support",
      "SSO",
      "Custom integrations",
    ],
  },
];
 
export default function PricingPage() {
  return (
    <div className="max-w-5xl mx-auto py-16 px-4">
      <h1 className="text-4xl font-bold text-center mb-4">Pricing</h1>
      <p className="text-gray-600 text-center mb-12">
        Start with a 14-day free trial. No credit card required.
      </p>
 
      <div className="grid md:grid-cols-3 gap-8">
        {tiers.map((tier) => (
          <div
            key={tier.name}
            className={`rounded-xl border p-8 ${
              tier.popular
                ? "border-blue-500 ring-2 ring-blue-500 relative"
                : "border-gray-200"
            }`}
          >
            {tier.popular && (
              <span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-blue-500 text-white text-xs px-3 py-1 rounded-full">
                Most Popular
              </span>
            )}
            <h2 className="text-xl font-bold">{tier.name}</h2>
            <p className="mt-4">
              <span className="text-4xl font-bold">${tier.price}</span>
              <span className="text-gray-500">/{tier.interval}</span>
            </p>
            <ul className="mt-6 space-y-3">
              {tier.features.map((feature) => (
                <li key={feature} className="flex items-center gap-2">
                  <span className="text-green-500">&#10003;</span>
                  {feature}
                </li>
              ))}
            </ul>
            <form
              action={createSubscriptionCheckout.bind(null, tier.priceId)}
              className="mt-8"
            >
              <button
                type="submit"
                className={`w-full py-3 rounded-lg font-medium ${
                  tier.popular
                    ? "bg-blue-600 text-white hover:bg-blue-700"
                    : "bg-gray-100 text-gray-800 hover:bg-gray-200"
                }`}
              >
                Start Free Trial
              </button>
            </form>
          </div>
        ))}
      </div>
    </div>
  );
}

Deep Dive

How It Works

  • Stripe subscriptions are built on three objects: Product (what you sell), Price (how much and how often), and Subscription (a customer's active plan).
  • Products and Prices can be created in the Stripe Dashboard or via the API. Each Price has an interval (day, week, month, year) and an amount.
  • When using Checkout with mode: "subscription", Stripe creates the Customer, Subscription, and handles the first payment automatically.
  • When using the API directly with payment_behavior: "default_incomplete", the subscription starts in an incomplete state until the first payment is confirmed client-side.
  • Subscription statuses flow through: trialing (if trial set) then active, past_due (payment failed), canceled, or unpaid.
  • Trial periods delay the first charge. During a trial, the subscription status is trialing and no invoice is generated until the trial ends.

Variations

Annual vs monthly pricing:

// Create both prices for the same product
const monthlyPrice = await stripe.prices.create({
  product: "prod_xxx",
  unit_amount: 2900,
  currency: "usd",
  recurring: { interval: "month" },
});
 
const annualPrice = await stripe.prices.create({
  product: "prod_xxx",
  unit_amount: 29000, // ~$241/mo -- annual discount
  currency: "usd",
  recurring: { interval: "year" },
});

Cancel a subscription:

// Cancel at end of billing period
await stripe.subscriptions.update(subscriptionId, {
  cancel_at_period_end: true,
});
 
// Cancel immediately
await stripe.subscriptions.cancel(subscriptionId);

Update subscription (change plan):

const subscription = await stripe.subscriptions.retrieve(subscriptionId);
 
await stripe.subscriptions.update(subscriptionId, {
  items: [
    {
      id: subscription.items.data[0].id,
      price: newPriceId,
    },
  ],
  proration_behavior: "create_prorations",
});

TypeScript Notes

  • stripe.subscriptions.create returns Promise<Stripe.Subscription>.
  • When using expand, the expanded fields change types. Cast explicitly after expansion.
  • Subscription status is a union type: "active" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "trialing" | "unpaid" | "paused".
import type Stripe from "stripe";
 
function isActive(subscription: Stripe.Subscription): boolean {
  return subscription.status === "active" || subscription.status === "trialing";
}

Gotchas

  • Never grant access based solely on a successful Checkout redirect. Always verify subscription status via webhooks (customer.subscription.created, invoice.paid).
  • When using payment_behavior: "default_incomplete", you must confirm the PaymentIntent client-side or the subscription stays incomplete and expires after 23 hours.
  • Proration happens by default when changing plans mid-cycle. Set proration_behavior: "none" to disable it.
  • cancel_at_period_end: true does not immediately cancel. The subscription stays active until the current period ends. Listen for customer.subscription.deleted to revoke access.
  • Trial periods created via Checkout require payment_method_collection: "if_required" if you do not want to collect a card upfront. The default collects a card.
  • Stripe creates a $0 invoice at trial start. This is normal and expected.

Alternatives

ApproachProsCons
Checkout Session (subscription mode)Minimal code, handles everythingLimited UI customization
API-created subscription + PaymentElementFull UI control, in-app checkoutMore complex setup
Payment LinksZero-code subscription setupNo programmatic control
Customer Portal for plan changesStripe-hosted management UILess integrated feel

FAQs

What are the three core Stripe objects that make up a subscription?
  • Product -- what you sell (e.g., "Pro Plan")
  • Price -- how much and how often (e.g., $29/month)
  • Subscription -- a customer's active association with a Price
What is the subscription status lifecycle?

trialing (if trial set) -> active -> past_due (payment failed) -> canceled or unpaid. The full union is: "active" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "trialing" | "unpaid" | "paused".

What does payment_behavior: "default_incomplete" do when creating a subscription via the API?

It starts the subscription in an incomplete state, requiring the client to confirm the first PaymentIntent. If not confirmed within 23 hours, the subscription expires automatically.

How do you cancel a subscription at the end of the current billing period vs immediately?
// End of period (subscription stays active until period ends)
await stripe.subscriptions.update(subId, {
  cancel_at_period_end: true,
});
 
// Immediately
await stripe.subscriptions.cancel(subId);
Gotcha: Why should you never grant access based solely on a successful Checkout redirect?

Users can manually navigate to your success URL. Always verify subscription status via webhooks (customer.subscription.created, invoice.paid) or by retrieving the session/subscription server-side.

What happens with proration when a user changes plans mid-cycle?

By default, Stripe creates a prorated credit for the remaining time on the old plan and charges the difference for the new plan. Set proration_behavior: "none" to disable this.

How do you create both monthly and annual prices for the same product?
await stripe.prices.create({
  product: "prod_xxx",
  unit_amount: 2900,
  currency: "usd",
  recurring: { interval: "month" },
});
await stripe.prices.create({
  product: "prod_xxx",
  unit_amount: 29000,
  currency: "usd",
  recurring: { interval: "year" },
});
Gotcha: Does cancel_at_period_end immediately cancel the subscription?

No. The subscription stays active until the current billing period ends. The user retains access during that time. Listen for customer.subscription.deleted to revoke access when it finally cancels.

What happens during a free trial period?
  • The subscription status is trialing
  • No charge occurs until the trial ends
  • Stripe creates a $0 invoice at trial start (this is normal)
  • By default, a card is collected upfront; use payment_method_collection: "if_required" to skip card collection
TypeScript: How do you check if a subscription is currently active?
import type Stripe from "stripe";
 
function isActive(sub: Stripe.Subscription): boolean {
  return sub.status === "active" || sub.status === "trialing";
}
TypeScript: Why do you need to cast expanded fields after retrieval?

When you use expand: ["latest_invoice.payment_intent"], the expanded objects are typed as their ID string by default. You must cast them to the actual types:

const invoice = subscription.latest_invoice as Stripe.Invoice;
const pi = invoice.payment_intent as Stripe.PaymentIntent;