Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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!,
};
}// 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>
);
}interval (day, week, month, year) and an amount.mode: "subscription", Stripe creates the Customer, Subscription, and handles the first payment automatically.payment_behavior: "default_incomplete", the subscription starts in an incomplete state until the first payment is confirmed client-side.trialing (if trial set) then active, past_due (payment failed), canceled, or unpaid.trialing and no invoice is generated until the trial ends.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",
});stripe.subscriptions.create returns Promise<Stripe.Subscription>.expand, the expanded fields change types. Cast explicitly after expansion."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";
}customer.subscription.created, invoice.paid).payment_behavior: "default_incomplete", you must confirm the PaymentIntent client-side or the subscription stays incomplete and expires after 23 hours.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.payment_method_collection: "if_required" if you do not want to collect a card upfront. The default collects a card.| 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 |
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".
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.
// 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);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.
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.
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" },
});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.
trialingpayment_method_collection: "if_required" to skip card collectionimport type Stripe from "stripe";
function isActive(sub: Stripe.Subscription): boolean {
return sub.status === "active" || sub.status === "trialing";
}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;Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥