Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Stripe plan switching in Next.js -- upgrade, downgrade, and proration without the double-charge

July 7, 2026
stripenextjsdrizzle-ormsaaspayments

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.

The core decision: upgrade now vs downgrade at renewal

When a customer switches plans, Stripe's behavior depends on what you pass as proration_behavior.

Two rules cover most SaaS products:

  • Upgrade (customer moves to a higher price): use 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.
  • Downgrade (customer moves to a lower price): use 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 service layer

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)
  }
}

Syncing the plan in Drizzle ORM

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.

The plan picker UI

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>
  )
}

One thing to add before you ship

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.