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">✓</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 anamount. - 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 anincompletestate until the first payment is confirmed client-side. - Subscription statuses flow through:
trialing(if trial set) thenactive,past_due(payment failed),canceled, orunpaid. - Trial periods delay the first charge. During a trial, the subscription status is
trialingand 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.createreturnsPromise<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 staysincompleteand expires after 23 hours. - Proration happens by default when changing plans mid-cycle. Set
proration_behavior: "none"to disable it. cancel_at_period_end: truedoes not immediately cancel. The subscription stays active until the current period ends. Listen forcustomer.subscription.deletedto 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
| Approach | Pros | Cons |
|---|---|---|
| Checkout Session (subscription mode) | Minimal code, handles everything | Limited UI customization |
| API-created subscription + PaymentElement | Full UI control, in-app checkout | More complex setup |
| Payment Links | Zero-code subscription setup | No programmatic control |
| Customer Portal for plan changes | Stripe-hosted management UI | Less 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;