Upgrading a Stripe subscription mid-cycle sounds like a one-liner. It is not. Get the proration logic wrong and you either charge the customer twice or silently eat a day of revenue. Get the downgrade timing wrong and you cut off a customer's access before their paid period ends.
Here is how to handle both cases correctly in a Next.js SaaS built on this boilerplate.
When a customer switches plans, Stripe's behavior depends on what you pass as proration_behavior.
Two rules cover most SaaS products:
create_prorations. Stripe calculates the unused days on the old plan, credits them, charges only the difference, and activates the new plan immediately. The customer gets more features right now and pays a fair amount.none. The current plan stays active through the end of the billing period. The lower price takes effect at the next renewal. Nobody gets cut off early.A third option -- always_invoice -- forces an immediate charge even for downgrades and is rarely the right default.
The plan switch belongs in your subscription service, not in the route handler.
// modules/subscription/subscription.service.ts
import { stripe } from '@/lib/stripe'
import { userRepo } from '@/modules/user'
import { HttpError } from '@/lib/errors'
export async function switchPlan(
userId: string,
newPriceId: string
): Promise<void> {
const user = await userRepo.findById(userId)
if (!user?.stripeSubscriptionId) throw new HttpError(400, 'No active subscription')
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId)
const currentPriceId = subscription.items.data[0].price.id
if (currentPriceId === newPriceId) return
const isUpgrade = await isPriceHigher(newPriceId, currentPriceId)
await stripe.subscriptions.update(user.stripeSubscriptionId, {
items: [{ id: subscription.items.data[0].id, price: newPriceId }],
proration_behavior: isUpgrade ? 'create_prorations' : 'none',
})
await userRepo.update(userId, { stripePriceId: newPriceId })
}
async function isPriceHigher(newPriceId: string, currentPriceId: string): Promise<boolean> {
const [newPrice, currentPrice] = await Promise.all([
stripe.prices.retrieve(newPriceId),
stripe.prices.retrieve(currentPriceId),
])
return (newPrice.unit_amount ?? 0) > (currentPrice.unit_amount ?? 0)
}
The route handler stays thin:
// app/api/subscription/switch/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getUserFromRequest } from '@/lib/auth'
import { switchPlan } from '@/modules/subscription/subscription.service'
import { handleError } from '@/lib/errors'
import { z } from 'zod'
const schema = z.object({ priceId: z.string().min(1) })
export async function POST(req: NextRequest) {
try {
const user = await getUserFromRequest(req)
const { priceId } = schema.parse(await req.json())
await switchPlan(user.id, priceId)
return NextResponse.json({ ok: true })
} catch (error: unknown) {
return handleError(error)
}
}
For upgrades (create_prorations), the plan changes immediately -- update stripePriceId in the service right after the Stripe call, as shown above.
For downgrades (none), the plan only changes at renewal. You still update the DB optimistically, but the real source of truth comes from the webhook. If you are handling customer.subscription.updated, add a plan sync there:
case 'customer.subscription.updated': {
const sub = event.data.object
const priceId = sub.items.data[0]?.price.id
if (priceId) {
await userRepo.updateByStripeCustomerId(sub.customer as string, {
stripePriceId: priceId,
stripeStatus: sub.status,
})
}
break
}
For more on the full webhook setup, see Stripe subscriptions in Next.js and how to gate features by plan in usage limits and plan gating.
Keep it simple: a card per plan, the current plan button disabled, and a single fetch call on click.
// components/subscription/PlanPicker.tsx
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
const PLANS = [
{ name: 'Starter', priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_STARTER!, label: '$19/mo' },
{ name: 'Pro', priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO!, label: '$79/mo' },
]
export function PlanPicker({ currentPriceId }: { currentPriceId: string }) {
const [loading, setLoading] = useState<string | null>(null)
async function switchTo(priceId: string) {
setLoading(priceId)
const token = localStorage.getItem('token')
const res = await fetch('/api/subscription/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ priceId }),
})
setLoading(null)
if (res.ok) toast.success('Plan updated')
else toast.error('Could not switch plan -- please try again')
}
return (
<div className="grid gap-4 sm:grid-cols-2">
{PLANS.map((plan) => (
<div key={plan.priceId} className="rounded-lg border p-6 flex flex-col gap-4">
<div>
<p className="font-semibold text-lg">{plan.name}</p>
<p className="text-muted-foreground">{plan.label}</p>
</div>
<Button
disabled={plan.priceId === currentPriceId || loading === plan.priceId}
onClick={() => switchTo(plan.priceId)}
>
{plan.priceId === currentPriceId ? 'Current plan' : 'Switch to this plan'}
</Button>
</div>
))}
</div>
)
}
Send a confirmation email the moment the plan changes. A single emailService.sendEmail() call in the service is all it takes. Customers who get a silent billing change feel surprised. Customers who get a confirmation email trust your product.
If you want Stripe, Drizzle ORM, JWT auth, and transactional email already wired up so you only have to write the plan switch logic above, clone the boilerplate and you can have this working in an afternoon.